-
Notifications
You must be signed in to change notification settings - Fork 9
feat(ai-cost): ingest Claude Team vendor invoices #2432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
2daca7f
feat(ai-cost): ingest Claude Team vendor invoices (#2429)
Gregory91G 9e4ecff
fix(ai-cost): date the invoice fixture in the year its assertions name
Gregory91G c462fe3
ci: run the invoice silver test in the ai shard
Gregory91G e631f35
docs(claude-team-invoices): carry the chain contract in the connector…
Gregory91G 32d60ff
Merge branch 'main' into feat-ai-cost-invoices
Gregory91G 8d50d9a
style(claude-team-invoices): apply the repo's pinned ruff to this con…
Gregory91G 97672f6
fix(claude-team-invoices): address the review findings on the invoice…
Gregory91G 1d85982
test(claude-team-invoices): isolate the recovery case behind its own …
Gregory91G 6cea2a4
fix(claude-team-invoices): ship the stream schema the connector reads
Gregory91G 46c2b11
test(claude-team-invoices): exercise the chain over HTTP, and run the…
Gregory91G ec175dc
test(stand): seed vendor invoices at bronze so the connector's model …
Gregory91G ab7cf45
test(claude-team-invoices): cover the connection check and the schema…
Gregory91G ca720d3
chore(ingestion): refresh the connectors-ddl snapshot for the invoice…
Gregory91G 3005f1e
test(claude-team-invoices): let a bare pytest collect the suite
Gregory91G 318d7f3
fix(claude-team-invoices): refuse a listing whose `invoices` is not a…
Gregory91G b8bb9cc
Merge branch 'main' into feat-ai-cost-invoices
Gregory91G 28f1b37
chore(security): waive the HEALTHCHECK rule for the invoice connector…
Gregory91G 648aaf4
style(seed): keep the selector's warning to the two lines the rule al…
Gregory91G 56dc9ed
test(claude-team-invoices): annotate the suite's signatures
Gregory91G f0fd744
refactor(seed): split the AI cost generators out of the usage module
Gregory91G d0478ef
Merge remote-tracking branch 'upstream/main' into inv-review-fixes
Gregory91G 0ae0948
fix(ai-cost): carry an invoice's money on its own row, keyed on the w…
Gregory91G eb7169f
fix(connectors): declare the invoice connector's required secret fields
Gregory91G ba1a186
fix(seed): clear the layers a re-seed rebuilds, and size only what ex…
Gregory91G File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
src/ingestion/connectors/ai/claude-team-invoices/Dockerfile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| FROM airbyte/python-connector-base:4.1.1 | ||
|
|
||
| COPY . /airbyte/integration_code | ||
| RUN pip install /airbyte/integration_code | ||
|
|
||
| # airbyte-cdk pins nltk==3.9.1 (fixable CVE-2025-14009, CVE-2026-0846, | ||
| # CVE-2026-33231, CVE-2026-54293) but only uses it in the file-based | ||
| # unstructured parser, which this connector never loads; defusedxml is | ||
| # nltk 3.10's added runtime dependency. poetry/dulwich are base-image build | ||
| # tooling unused at runtime (CVE-2026-34591, CVE-2026-42305). | ||
| RUN pip install --no-cache-dir --no-deps nltk==3.10.0 defusedxml==0.7.1 \ | ||
| && pip uninstall -y poetry dulwich | ||
|
|
||
| RUN useradd --create-home --no-log-init --shell /bin/bash airbyte_user | ||
| USER airbyte_user | ||
|
|
||
| ENV AIRBYTE_ENTRYPOINT="source-claude-team-invoices" | ||
| ENTRYPOINT ["source-claude-team-invoices"] | ||
76 changes: 76 additions & 0 deletions
76
src/ingestion/connectors/ai/claude-team-invoices/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| # Claude Team invoices | ||
|
|
||
| The invoiced layer for Claude Team: what Anthropic actually billed, and the only | ||
| place a seat's price appears. | ||
|
|
||
| ## Why a CDK connector | ||
|
|
||
| The invoice list alone is declarative-shaped. Its line items are not: the | ||
| claude.ai wrapper carries no invoice id and no lines, only a | ||
| `hosted_invoice_url`, and reaching the lines takes a three-hop chain across two | ||
| hosts where each hop's credential comes out of the previous hop's response. | ||
| Same reasoning as github-copilot ADR-0001. | ||
|
|
||
| ## The chain | ||
|
|
||
| | Step | Request | What it yields | | ||
| |---|---|---| | ||
| | 1 | `GET {proxy}/api/stripe/{org}/invoices` | the wrapper's rows: `total`, `total_excluding_tax`, `currency`, `status`, `num_seats`, `hosted_invoice_url` — no invoice id, no lines | | ||
| | 2 | parse `https://invoice.stripe.com/i/{acct}/{token}?s=ap` | the account and token identifiers | | ||
| | 3 | `GET invoicedata.stripe.com/hosted_invoice_page/{acct}/{token}` | `invoice_id` and a short-lived `ephemeral_key` | | ||
| | 4 | `GET api.stripe.com/v1/invoices/{id}/lines?limit=100`, paginated on `has_more` | the full line set | | ||
|
|
||
| Step 3 carries no header: the token inside its URL is what authorises it. | ||
| Step 4 sends the ephemeral key, `Stripe-Version: 2026-06-24.dahlia` and | ||
| `Stripe-Account: {acct}` on every line page. The key is short-lived and is | ||
| never written to a record, a state message or a log line. | ||
|
|
||
| Follow a URL inside the run that fetched it. Stripe expires a hosted invoice URL | ||
| 30 days after the due date, and claude.ai re-issues a fresh one on every list | ||
| call — so storing a URL and following it later works in every test and then | ||
| fails in production, oldest invoices first. | ||
|
|
||
| **What the connector must be allowed to reach.** Steps 3 to 5 leave the cluster | ||
| for `invoicedata.stripe.com` and `api.stripe.com`; where a `NetworkPolicy` | ||
| governs egress it has to admit both. A blocked host surfaces as a chain step | ||
| that did not complete, not as missing money. | ||
|
|
||
| ## What a line means | ||
|
|
||
| Categories come from the Stripe parent, never from the description — a | ||
| subscription-item parent is the recurring seat charge, an invoice-item parent is | ||
| prepaid extra usage. | ||
|
|
||
| A seat price is `hosted_invoice_unit_amount` on a non-proration subscription | ||
| line. It is not `amount / quantity`: a mid-period seat change emits proration | ||
| lines whose amounts cover part of a period, and dividing those yields a number | ||
| that is not a price. It is also not the wrapper's `num_seats`, which may be | ||
| absent and, where present, reports one line's quantity while an invoice can | ||
| price several tiers. | ||
|
|
||
| An invoice may price several tiers at once, each on its own line — so a seat | ||
| price binds to a tier and reaches a person through `class_ai_overage.seat_tier`, | ||
| never by dividing an invoice total. | ||
|
|
||
| `period` dates a line to the window it charges for, which is not always the | ||
| window the invoice was raised in. A line is filed by its period, never by the | ||
| invoice date. | ||
|
|
||
| Prepaid extra-usage purchases arrive as invoices of their own, carrying a single | ||
| `overusage` line at `quantity: 1`. They are the invoiced-layer counterpart of | ||
| the extra usage a seat later consumes, and they price no seat. | ||
|
|
||
| ## Degradation | ||
|
|
||
| Every invoice emits its own row carrying its money and how far its chain got; | ||
| lines are added beside it only when the chain completed. So a chain failure keeps | ||
| the money on the ledger without a fabricated price, and the invoice keeps one row | ||
| across attempts — a later run that enriches it replaces that row instead of | ||
| adding its money a second time. | ||
|
|
||
| `chain_status` distinguishes four outcomes: `ok`, `failed` (a hop answered | ||
| badly), `unparsable_url` (a hosted URL was offered but no longer matches), and | ||
| `no_hosted_url` (none was offered, as on a draft invoice). Only URLs that were | ||
| offered count towards drift: if more than half of them fail to parse the run | ||
| fails instead, because that is a format change, and a run of unpriced rows would | ||
| read as the vendor having stopped charging for seats. |
104 changes: 104 additions & 0 deletions
104
src/ingestion/connectors/ai/claude-team-invoices/dbt/claude_team__ai_invoice.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| -- depends_on: {{ ref('claude_team_invoices__bronze_promoted') }} | ||
| -- Bronze → Silver: Claude Team vendor invoices → class_ai_invoice | ||
| -- | ||
| -- Source: bronze_claude_team_invoices.claude_team_invoice_lines — one row per | ||
| -- invoice plus one per invoice line, produced by the CDK connector that walks the | ||
| -- claude.ai invoice wrapper and follows the Stripe hosted chain behind it. | ||
| -- | ||
| -- This is the FIRST contributor to class_ai_invoice and therefore DEFINES its | ||
| -- positional contract (consumed by union_by_tag('silver:class_ai_invoice')). | ||
| -- Vendor-specific extras go into invoice_metrics_json, never new columns. | ||
| -- | ||
| -- GRAIN: one row per (tenant, source, invoice) carrying that invoice's own | ||
| -- money, plus one row per (tenant, source, invoice, line). Invoice money is on | ||
| -- the invoice's row alone, so summing it needs no dedup and an invoice whose | ||
| -- chain later completes replaces its own row instead of adding a second one. | ||
| -- Aggregating to category is gold's job — a class that pre-aggregated would make | ||
| -- the per-tier seat price unrecoverable. | ||
| -- | ||
| -- UNITS: Stripe amounts are ALREADY minor units (cents), so they map straight | ||
| -- through with NO ×100 — same as claude_team__ai_overage, unlike | ||
| -- claude_team__ai_dev_usage. | ||
| -- | ||
| -- PERIOD: dated by the window the line CHARGES for, not by the day the invoice | ||
| -- was issued — a monthly invoice is raised at the period boundary and would | ||
| -- otherwise land in the neighbouring month. Rows carrying no line fall back to | ||
| -- the invoice date. | ||
| -- STRATEGY: delete+insert, not append. An invoice's row is rewritten whenever its | ||
| -- chain gets further, so the same unique_key arrives twice; appending would leave | ||
| -- both versions standing until a background merge collapsed them, and the | ||
| -- `unique` test reads without FINAL. | ||
| {{ config( | ||
| materialized='incremental', | ||
| incremental_strategy='delete+insert', | ||
| unique_key='unique_key', | ||
| engine='ReplacingMergeTree(_version)', | ||
| order_by=['unique_key'], | ||
| on_schema_change='append_new_columns', | ||
| settings={'allow_nullable_key': 1}, | ||
| schema='staging', | ||
| tags=['claude-team-invoices', 'silver:class_ai_invoice'] | ||
| ) }} | ||
|
|
||
| WITH latest_per_key AS ( | ||
|
|
||
| -- Bronze is full-refresh+append: every sync re-emits every invoice and line | ||
| -- under the same unique_key, and an invoice's row changes when its chain | ||
| -- finally completes. Collapse to the freshest read per key. | ||
| SELECT * | ||
| FROM {{ source('bronze_claude_team_invoices', 'claude_team_invoice_lines') }} | ||
| WHERE unique_key IS NOT NULL | ||
| AND unique_key != '' | ||
| ORDER BY _airbyte_extracted_at DESC | ||
| LIMIT 1 BY unique_key | ||
|
|
||
| ) | ||
|
|
||
| SELECT | ||
| tenant_id AS insight_tenant_id, | ||
| source_id, | ||
| unique_key, | ||
| invoice_id, | ||
| line_id, | ||
| 'claude' AS tool, | ||
| invoice_status, | ||
| -- Chain outcome, carried so an unenriched invoice reads as unenriched | ||
| -- rather than as an invoice that happened to have no lines. | ||
| chain_status, | ||
| category, | ||
| tier_label, | ||
| toUInt8(coalesce(is_proration, false)) AS is_proration, | ||
| coalesce(currency, invoice_currency, 'usd') AS currency, | ||
| toStartOfMonth(toDateTime(toInt64(coalesce(period_start_ts, invoice_created_ts, 0)))) AS period_month, | ||
| toInt64OrNull(toString(round(amount))) AS amount_cents, | ||
| -- The per-seat price the vendor states on the line. NULL on prorations and | ||
| -- on extra usage — honest-NULL: neither prices a seat. | ||
| toInt64OrNull(toString(round(seat_unit_amount))) AS seat_unit_cents, | ||
| toInt64OrNull(toString(round(quantity))) AS seat_quantity, | ||
| toInt64OrNull(toString(round(invoice_total_excluding_tax))) AS invoice_net_cents, | ||
| -- Vendor extras kept out of the positional contract. | ||
| toJSONString(map( | ||
| 'product_name', ifNull(toString(product_name), ''), | ||
| 'description', ifNull(toString(description), ''), | ||
| 'num_seats', ifNull(toString(invoice_num_seats), ''), | ||
| 'invoice_total', ifNull(toString(invoice_total), ''), | ||
| 'period_end_ts', ifNull(toString(period_end_ts), '') | ||
| )) AS invoice_metrics_json, | ||
| 'claude_team' AS source, | ||
| data_source, | ||
| CAST(_airbyte_extracted_at AS Nullable(DateTime64(3))) AS collected_at, | ||
| toUnixTimestamp64Milli(_airbyte_extracted_at) AS _version | ||
| FROM latest_per_key | ||
| {% if is_incremental() %} | ||
| -- A row only ever changes because a newer read produced it, so rows read since | ||
| -- the last build are the only ones that can carry anything new. The empty-table | ||
| -- guard mirrors the sibling models: over an empty `this` the max is the epoch | ||
| -- and every row would be filtered out. | ||
| WHERE ( | ||
| (SELECT count() FROM {{ this }}) = 0 | ||
| OR _airbyte_extracted_at > ( | ||
| SELECT coalesce(max(collected_at), toDateTime64('1970-01-01 00:00:00', 3)) | ||
| FROM {{ this }} | ||
| ) | ||
| ) | ||
| {% endif %} |
19 changes: 19 additions & 0 deletions
19
...ngestion/connectors/ai/claude-team-invoices/dbt/claude_team_invoices__bronze_promoted.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| {# ------------------------------------------------------------------------- | ||
| Bootstrap model for claude-team-invoices bronze → RMT promotion. | ||
|
|
||
| Counterpart of `claude_team__bronze_promoted` for the invoice connector, | ||
| which owns its own bronze namespace. See ADR-0002. The | ||
| `promote_bronze_to_rmt` macro is idempotent — already-RMT tables are | ||
| detected and skipped on subsequent runs. | ||
| ------------------------------------------------------------------------- #} | ||
|
|
||
| -- @cpt-principle:cpt-dataflow-principle-promote-bronze:p1 | ||
| {{ config( | ||
| materialized='view', | ||
| schema='staging', | ||
| tags=['claude-team-invoices'] | ||
| ) }} | ||
|
|
||
| {% do promote_bronze_to_rmt(table='bronze_claude_team_invoices.claude_team_invoice_lines', order_by='unique_key') %} | ||
|
|
||
| SELECT 1 AS promoted |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.