Skip to content

Feat/focus ledger schema - #33

Merged
JetSquirrel merged 5 commits into
mainfrom
feat/focus-ledger-schema
Sep 1, 2026
Merged

Feat/focus ledger schema#33
JetSquirrel merged 5 commits into
mainfrom
feat/focus-ledger-schema

Conversation

@JetSquirrel

Copy link
Copy Markdown
Owner

No description provided.

JetSquirrel and others added 5 commits August 29, 2026 16:52
The DuckDB file was a cache, not a ledger: two per-account tables of
display-shaped JSON, plus a cost_data table nothing wrote. Sankey,
attribution, anomaly detection and month-end freezing all need a fact
table to build on, so this adds one.

billing.duckdb is a separate file from the application state, because
accounts and caches are user-entered or re-fetchable while the ledger is
the record that has to survive. It holds fct_charge, ingest_batch,
fct_balance_snapshot and dim_fx_rate, named after FOCUS columns so a
later ingest of a real CUR or bill export needs no schema change.

Writes are whole-period replacement in one transaction: providers
re-issue a bill in full mid-month and correct prior months, so a
row-by-row upsert would leave behind rows the provider has since deleted
and the total would stop matching theirs. Charge ids are a hash of the
row's natural key, which is what makes "run ingest twice, get identical
results" checkable; rows whose natural keys collide get an occurrence
suffix rather than being folded together, so no money goes missing.

Nothing normalizes into the ledger yet — PR4 and PR5 do — so the cache
tables stay for now. Dropping them before then would mean paying Cost
Explorer for a fetch on every launch.

The application database is versioned and rebuilt at v1 along the way:
cost_data is gone, provider is now source_id, and the credential columns
are dropped after any secret still in them is moved to the OS keyring.
It is a rebuild rather than a sequence of ALTERs because DuckDB will not
alter a table a foreign key points at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fetching and interpreting were one step: get_cost_summary() called Cost
Explorer and returned a display-shaped struct, so the response existed
only long enough to be reshaped. Cost Explorer bills per request, which
made every mapping change cost another round of paid calls, and there was
no way to assert on billing logic without a live account.

fetch now returns what the provider sent, unchanged, and nothing else.
The payloads are written as Hive-partitioned Parquet under
raw/provider=/account=/billing_period=/batch=, the layout a bill export
bucket already uses, so P1's S3/OSS channel replaces only the fetch half.
normalize is a pure function from a stored batch to FOCUS rows: no clock,
no network, no database — the fetch time it needs rides along on the
batch. Each source is now tested against a recorded response.

The two Cost Explorer methods were near-identical copies differing in one
field of the request body; they collapse into one signed call plus a
request builder.

What the normalizers do not do yet is the mapping detail PR4 and PR5 own.
AWS asks only for UnblendedCost and files every row as Usage, because
without RECORD_TYPE in the grouping the payload cannot tell a credit from
a charge and guessing would put refunds on the wrong side of the total.
Alibaba Cloud keeps the discount visible as the gap between billed_cost
and list_cost rather than inventing Credit rows. DeepSeek writes balance
snapshots and derives no top-ups.

ingest_period ties the three steps to one batch id, shared by the raw
partition and the ledger batch; renormalize_period replays the newest
stored batch without fetching, which is the whole reason the raw store
exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cost Explorer was asked for UnblendedCost grouped by service, which is one
unlabelled amount per service per day. A credit, a refund, a tax line and a
support fee all arrived looking like usage, and there was no amortized
figure at all, so a reserved instance paid for up front showed as a spike
in the month it was bought and as free capacity for a year afterwards.

The ingest request now carries UnblendedCost, AmortizedCost and
UsageQuantity in one call — Cost Explorer bills per request, not per
metric — and groups by RECORD_TYPE as well as SERVICE. The record type
maps to charge_category, so the ledger can tell a credit from a charge.

Amounts keep the sign Cost Explorer gives them, so a period total is a
plain sum. An unrecognized record type becomes an Adjustment with a
warning naming it: money moved, and filing it as Usage would quietly
inflate what reads as consumption.

A row is dropped only when both cost metrics are zero. Usage covered by a
savings plan is zero unblended and non-zero amortized; dropping it would
lose what the commitment bought. Grouping by service also mixes usage
types, which Cost Explorer signals by returning the unit N/A — a quantity
like that is not stored, because it cannot be added to anything.

Which key is which is read from the response's GroupDefinitions rather
than assumed from the request this build would have sent, so payloads
already in the raw store still normalize; they normalize as Usage, which
is what they always were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alibaba Cloud reports gross and net on the same line, so a voucher was
only visible as the difference between two numbers. Each deduction —
InvoiceDiscount, coupons, cash coupons, a stored-value card — is now a
Credit row of its own beside a usage charge carrying the gross amount.

Putting the net amount on the usage row and the deductions beside it
would have counted them twice; AWS bills a discount as a separate line,
Alibaba Cloud does not. Decomposed this way a product's rows sum to
PretaxAmount, which is what was actually charged, so a period total stays
a plain sum. When the deductions we can name do not close the gap between
gross and net, the remainder becomes one Adjustment row and a warning
naming the product: the bill accounts for that money even where this
parser cannot, and dropping it would leave the ledger disagreeing with
the invoice.

DeepSeek publishes a balance, which is state. It says what is left, never
what was bought, so a purchase can only be inferred from movement: a rise
in the topped-up balance between two consecutive observations is money
that went in. Those rows are derived at ingest rather than written once,
because replacing a period clears whatever was there before — recomputing
them keeps a re-ingest idempotent. The first observation of an account
yields nothing: a balance that was simply there the first time it was
looked at was not witnessed being paid for, and inventing a purchase for
it would drop the whole opening balance into whichever month the account
happened to be added.

Balances that differ only in currency never derive a purchase from each
other, and a falling topped-up balance is consumption, not a top-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard added AWS dollars to Alibaba Cloud yuan and printed the
result with a dollar sign. It also asked each provider for a
display-shaped summary and kept a copy in two cache tables, so the numbers
on screen were whatever the last API call happened to return.

Charges are now read through v_charge_normalized, which converts each one
at a rate dated no later than the charge itself. Conversion lives in a
view rather than at write time because rates get corrected and the
reporting currency is the user's to change; either would otherwise mean
rewriting the fact table. Switching currency in Settings replaces a view
and rewrites nothing.

A charge already in the reporting currency converts at 1.0 without needing
a row in the rate table. One whose currency no rate covers keeps a NULL
converted amount: it is counted nowhere and reported on the dashboard as
an explicit "not included" line, because a total that quietly folds in an
unconverted amount at par is worse than one that admits what it is
missing.

The cache tables go with it (application schema v2). ingest_batch already
records when each period was written, so that is what the freshness window
checks now. The trend chart reads the daily rows the refresh already
stored instead of paying for a third Cost Explorer call, and a source's
trend window becomes how far back its own rows are worth charting —
Alibaba Cloud's monthly rows get two billing periods rather than seven
empty days.

With nothing left reading them, get_cost_summary, get_cost_trend and
get_cost_data are gone from the trait and from all three sources, along
with the parsers and aggregators behind them. A source fetches and
normalizes; what the numbers mean afterwards is the ledger's business. The
clients also stop carrying an account id and name they no longer use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JetSquirrel
JetSquirrel merged commit c7afccb into main Sep 1, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant