Skip to content

fix(ai-cost): invoice connector follow-up — key an invoice on its own identity - #2657

Merged
hello1101n merged 6 commits into
mainfrom
invoice-identity-key
Aug 19, 2026
Merged

fix(ai-cost): invoice connector follow-up — key an invoice on its own identity#2657
hello1101n merged 6 commits into
mainfrom
invoice-identity-key

Conversation

@Gregory91G

Copy link
Copy Markdown
Contributor

Follow-up to #2432, under #2429.

Why. An invoice's money can be counted twice, silently: nothing downstream deletes the row a re-keyed invoice leaves behind, and the coverage check only looks for missing lines.

What changed. The invoice row keys on the identity decoded out of its hosted URL — stable_invoice_ref base64-decodes the live_… segment to acct_…,_<entity>, which survives the rotation the vendor applies on every list call — instead of on the wrapper's total, payment intent and dates, which move.

The rest of #2432's review. A changed seat-price type degrades the invoice instead of blanking every price; drafts are not emitted; chains are 250 ms apart. One commit each.

The wrapper's fields stay as a fallback. An invoice the vendor offers no URL for has no identity, and a collidable key beats losing it.

No migration for the new bronze column. reconcile_bronze_schema.py heals warm tables from the snapshot before migrations run — the route #2431 and #2545 took.

Out of scope. Skipping the chain for invoices already enriched — it needs the identity map carried in Airbyte state, and is tracked under #1607.

Verified. metrics/test_ai_invoice_silver.py 11 passed from wiped volumes; connector suite 77 passed; check-field-parity.py 0 failures; the upgrade path exercised on a throwaway cluster built from the previous snapshot. The snapshot was reproduced by hand — the connectors-ddl lane re-dumps it on every PR.

@Gregory91G
Gregory91G requested a review from a team as a code owner August 19, 2026 08:18
@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1138f6d0-fc01-4716-b13e-bbae194f5c6e


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`seat_unit_amount` returns None for anything that is not an int, so if the
hosted-invoice surface ever serialises the unit amount as a float or a digit
string, every seat price in the run becomes NULL while `chain_status` still reads
`ok` — indistinguishable from a vendor that prices no seats, and invisible to the
coverage check, which only looks at rows that are not `ok`.

A seat-pricing line whose amount arrives as another type now degrades its invoice
to `failed`, which is the state the coverage check already watches and the
remediation already explains. Absence keeps its own meaning: a subscription line
the vendor left unpriced is a state this connector reports, not a shape it failed
to read, so only a changed type degrades.

The warning carries the offending type by name. A `StripeChainError` is authored
here and names what the response got wrong, so its message is logged; anything
else still contributes its type and status alone, because a request error
stringifies its URL and the hosted-invoice hop carries the token in that URL. A
bare `failed` row would otherwise send the operator to the egress and
Stripe-Version checks the remediation names, neither of which is the fault.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
A draft has no hosted invoice URL — the vendor issues one only at finalisation —
so there is nothing invariant to identify it by, and its row can only be keyed on
what the wrapper reports: created timestamp, payment intent, total and due date.
Those are exactly the fields that move when the draft is finalised. The payment
intent is created at that moment and a draft's total can still change until then,
so the same invoice would be keyed one way as a draft and another once finalised:
the draft's row stays in the class beside the real one, and a sum over
`invoice_net_cents` counts that invoice twice. Nothing would surface it, because a
draft carries no lines and the coverage check only looks for missing lines.

Skipping them is the cheaper guard than keeping their money off the row, and it
is also the truthful one: a draft is not invoiced, and this is the invoiced layer.
Its total is provisional by definition.

Two things fall out. `no_hosted_url` now means only what it should — the vendor
offered no hosted URL for an invoice it has finalised, which is a vendor-side
change rather than a legitimate state — so the coverage check no longer needs to
exclude drafts and reports every status again. And drafts stay out of the drift
ratio, where two of them beside one malformed URL could have tipped a healthy run
into refusing to write.

The count of skipped drafts is logged: an operator comparing the vendor's list
against ours should not have to guess why the totals differ.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
A run walks every invoice the organisation has, and each one costs two requests
to `invoicedata.stripe.com` — the host this connector's own README names as its
known risk, since it appears in no Stripe documentation and carries no contract.
Nothing paced them.

A quarter-second sits between consecutive chains, and only between them: the
hops inside one chain stay back to back, because the ephemeral key that
authorises the last one is short-lived. The reference implementation paces the
same boundary by the same amount.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
An invoice's own row was keyed on what the wrapper reports — created timestamp,
payment intent, total and due date. Everything but the first can move over an
invoice's life, and when the key moves the old row stays: silver has no deletion
path, because class_ai_invoice filters its input with `_version > max(_version)`
and delete+insert deletes only keys present in the incoming set. The invoice's
money is then counted twice, and nothing surfaces it.

The hosted invoice URL carries an identity that does not move. Its
`live_…`/`test_…` segment is base64 of `acct_<account>,_<entity>,<rotating>`, and
only the trailing field is re-issued on every list call. `stable_invoice_ref`
decodes the first two into `invoice_ref`, `invoice_identity` puts it on every row
of an invoice, and `unique_key_parts` keys on it — falling back to the wrapper's
fields only where the vendor offered no URL and there is nothing else to key on.

The ref reaches the class through `invoice_metrics_json` rather than as a column
of its own: the staging model's header reserves the class contract for facts every
contributor can supply, and vendor extras go into the JSON.

A run whose URLs stop decoding is refused before the first chain call. Re-keying a
whole run onto the mutable fields would write a second copy of every invoice that
already has a row, and nothing downstream can delete the originals — the same
reasoning as the existing unparsable-URL guard, at the same ratio.

Two unit premises stopped being true and are replaced by the stronger claim they
were reaching for: comparing a with-URL failure to a without-URL one no longer
describes one invoice, and two invoices issued in one second are separated by
their identities rather than by their totals. The e2e recovery fixture keyed its
failed sync on the fallback while the recovered sync keyed on the identity, so the
pair had stopped describing one invoice — a chain only fails once the URL has
decoded, so both syncs carry the ref. The second-instance fixture gets one for
the same reason: left without, it would exercise the fallback while every other
invoice beside it exercises the identity, and nothing would say so.

The bronze column needs no migration: reconcile_bronze_schema.py derives its ADD
COLUMN statements from the snapshot and heals warm tables before the migrations
run.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
…end convention

The header said delete+insert was chosen because the same unique_key arrives
twice. True, but it reads as a local preference, and the conventions check
prescribes append for staging and delete+insert for silver — so the next reader
to run that check sees a violation with no argument against it.

The argument is the invariant: an invoice's row is REWRITTEN as its chain gets
further, so a later sync has to replace the row an earlier one wrote rather than
stand beside it. Appending leaves both until a background merge collapses them,
which makes the replacement unobservable. The models the convention was written
for restate a value under a key that never moves, which union_by_tag already
resolves by _version — not this case.

Comment only.

Signed-off-by: Gregory Gogin <grigoriy.gogin@constructor.tech>
@Gregory91G
Gregory91G force-pushed the invoice-identity-key branch from 8189a3d to cae0a15 Compare August 19, 2026 10:40
@hello1101n
hello1101n enabled auto-merge August 19, 2026 13:41
@hello1101n
hello1101n added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit 6346c92 Aug 19, 2026
60 of 61 checks passed
@hello1101n
hello1101n deleted the invoice-identity-key branch August 19, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component:ingestion dbt / approved connector scope team:data Ingestion and dbt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants