feat(aws-marketplace): bill a listing's LLM usage to AWS through a per-period metering tally

Merged
WA
3d ago
+34462-12

Summary

  • Plans carry their usage pricing: platform-with-usage bills the organization's billed model usage (BYOK is model_unbilled and never billed) as llm_credits of $0.01; contract bills nothing beyond the contract. Repricing or changing what counts is a plan edit
  • Metering charges what changed in the books, not hours of rows: usage_log cost is topped up in place (recordCumulativeUsage), so each run re-sums every billing period with a row that can still change (a request only raises its row while it runs, so a row settles after the longest execution timeout plus a day for late callbacks) and charges the whole units above that period's tally
    • aws_marketplace_usage_tally (organization, dimension, period): cost counted, units charged, and where the count starts. Fractions wait in the tally; units only move forward
    • aws_marketplace_usage_report (license, dimension, hour), the key AWS deduplicates on: a row gathers units while its hour is open and is sent once it closes, or at once for an ended license
  • The charged license is the earliest-linked one in force (status active and not past expiresAt). A license ends at the earlier of its passed expiry and the check that first finds it inactive, so overlapping licenses never both bill and a renewal handoff needs no partial-hour logic. With none in force, a license that ended within the hour AWS allows takes the usage at the hour it ended, and that record is sent at once (AWS's hour runs from the unsubscribe, which a check only finds later, so later tail usage is deliberately not billed)
  • aws_marketplace_license.ended_at, stamped by the read that first finds a license inactive and cleared if access returns, so a cancellation AWS reports with no entitlement (and no expiry) still gets its final flush
  • aws_marketplace_license.organization_linked_at, stamped wherever a license is linked; usage counts from it. A linked license without one (linked before it existed) is stamped by the first run that sees it
  • Sending: BatchMeterUsage by LicenseArn + CustomerAWSAccountId (no ProductCode), 25 per call, grouped per license. Success / DuplicateRecord → reported; CustomerNotSubscribed or a license-level refusal → rejected and logged; unprocessed or transient → pending. Rows AWS won't take (24h old, or last month's after 06:00 UTC on the 1st) are rejected and logged before sending; a TimestampOutOfBoundsException still raised for a batch is narrowed by sending each row alone
  • A record's quantity is fixed once it is first sent (sent_at, stamped under the organization's usage lock with the quantity re-read), since AWS keeps the first record it accepts: a retry resends the same quantity, and later usage waits for a record that can still take it
  • Cron /api/cron/aws-marketplace-meter every 15 minutes (scheduled in infra; SaaS fulfillment only runs on the hosted deployment, like aws-marketplace-reconcile)
  • Report rows keep their license on organization deletion (organization_id set null), so usage already charged is still sent
  • Migration 0456 is additive only

Type of Change

  • New feature

Testing

  • metering.integration.ts (17, real Postgres; only the AWS metering client is mocked) + lifecycle.integration.ts link-time and ended_at coverage; marketplace integration suites 89/89, unit 10/10
  • Each guard was removed in turn and its test went red: whole-period re-sum (in-place top-ups), send only after the hour closes, send an ended license at once, stop charging once its end-hour record is sent, skip a license past expiry, stamp unmarked links, period settle window, category filter, count from link, ended_at stamp / keep / clear, callers relinking only on change, the charge-side and send-side sent_at freeze, out-of-range isolation, month-end cutoff and its grace, the long-run row settle window, expiry as end, expired rows sent at once, report rows surviving org deletion
  • bun run lint, tsc, check:migrations, check:audits (the only local failure is the gitignored docs .content registry CI generates), drizzle-kit generate reports no drift

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing (new tests pass the test-audit authoring gate)
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)