openvibers/openvibe.billing Commits

Compare only money fields when a usage reading is replayed

OP OpenVibers committed 4 Oct 2026 · 2de3a1d7b2 · 5 files, +73 −8

Asked: T5 step 8, B1. OpenVibe.Billing: a replayed usage reading is compared on its money fields only. Money path. ONE pull request from current origin/main (git fetch first). Design: /home/workstation/openvibe/agents/jobs/network-t5-step-8-design-170122.out.md §2 D2, §3 rule 6, §4 row B1.
Harness job billing-t5-step-8-b1-openvibe-billing-171236 (plan T5).

Changes

ddiff --git a/README.md b/README.md
iindex 529169e..3f67fd1 100644
---- a/README.md
++++ b/README.md
@@@ -203,7 +203,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, promo_bits, payable_value_cents}` (`promo_bits`: unused free allowance, never transferable) |
| `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |
--| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing here: the rating sweep does, see Rates; accepted while frozen); idempotent by the reading's `idempotency_key`: same reading → 200 with the stored row (`Idempotent-Replayed: true`), a different one → 409 `billing.usage_key_reused`; no `Idempotency-Key` header |
++| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing here: the rating sweep does, see Rates; accepted while frozen); idempotent by the reading's `idempotency_key`: same money fields (`service`, `project`, `subject`, `resource`, `provider`, `operation`, `quantity`, `unit`, `at`; `at` as an instant, absent = null) → 200 with the first stored row, unchanged (`Idempotent-Replayed: true`), different money fields → 409 `billing.usage_key_reused`; placement and correlation fields (`node`, `cell`, `region`, `route_epoch`, `trace_id`, `source`, `cost_estimate`, `free_allowance_used`, `vibes_charged`) never cause a 409; no `Idempotency-Key` header |
| `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) |
| `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks |
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
ddiff --git a/docs/cutover-pr-openvibe-billing-6.md b/docs/cutover-pr-openvibe-billing-6.md
iindex cf1dc19..f5c38ec 100644
---- a/docs/cutover-pr-openvibe-billing-6.md
++++ b/docs/cutover-pr-openvibe-billing-6.md
@@@ -118,8 +118,14 @@ on the harness says `{"ok": true}`. This section is the rehearsal.
```
A valid `platform.usage-sample@1` body returns 201 with `record` populated;
the same body again returns 200 with `Idempotent-Replayed: true` and the
-- stored row; a body with a different reading under the same key returns 409
-- `billing.usage_key_reused`. `GET /api/v1/usage?project=...` lists the
++ stored row; a body under the same key whose money fields differ (`service`,
++ `project`, `subject`, `resource`, `provider`, `operation`, `quantity`,
++ `unit`, `at` — `at` compared as an instant, absent = null) returns 409
++ `billing.usage_key_reused`. Only those fields are compared: a body that
++ differs only in `node`, `cell`, `region`, `route_epoch`, `trace_id`,
++ `source`, `cost_estimate`, `free_allowance_used` or `vibes_charged` is a
++ 200 replay of the first stored row (`reading_hash` is informational).
++ `GET /api/v1/usage?project=...` lists the
row newest-first. Reject 422 for an invalid reading; reject 401 without a
token; reject 403 with a token that lacks `billing.usage.record`.
7. Write the green marker:
ddiff --git a/server/ops/usage.js b/server/ops/usage.js
iindex 90a2567..b4771f5 100644
---- a/server/ops/usage.js
++++ b/server/ops/usage.js
@@@ -9,8 +9,11 @@
*
* Idempotent by the reading's own idempotency_key (the contract's dedupe key, stable across retries, unique across
* every caller): the first reading under a key is stored; the same reading again returns the stored row (replay);
-- * a different reading under the same key is refused (409 billing.usage_key_reused) and the stored one is kept.
-- * "The same" is the canonical JSON (sorted keys), so key order and whitespace don't matter.
++ * a reading whose money fields differ under the same key is refused (409 billing.usage_key_reused) and the stored one
++ * is kept. Only the money fields (MONEY_FIELDS) are compared: `at` as an instant, quantity as a number, absent = null.
++ * Placement and correlation fields (node, cell, region, route_epoch, trace_id, source, id, ...) never make a 409: a
++ * producer re-sending second n of a job from another node is a replay, and the first stored reading wins.
++ * reading_hash (sha256 of the canonical JSON) is still written, for information only.
*/
const crypto = require('crypto');
const { validate } = require('openvibe-contracts');
@@@ -22,6 +25,20 @@ const MAX_PAGE = 500;
const hashOf = (reading) => crypto.createHash('sha256').update(stable(reading)).digest('hex');
++/** The fields that decide what a reading costs: two readings under one key that agree on these are the same reading. */
++const MONEY_FIELDS = ['service', 'project', 'subject', 'resource', 'provider', 'operation', 'quantity', 'unit', 'at'];
++
++function moneyFieldsEqual(a, b) {
++ return MONEY_FIELDS.every((f) => {
++ const x = a[f] ?? null;
++ const y = b[f] ?? null;
++ if (x === null || y === null) return x === y;
++ if (f === 'at') return Date.parse(x) === Date.parse(y);
++ if (f === 'quantity') return Number(x) === Number(y);
++ return x === y;
++ });
++}
++
function present(row) {
return {
id: String(row.id), idempotency_key: row.idempotency_key, project: row.project, subject: row.subject, service: row.service,
@@@ -54,8 +71,9 @@ async function record(ctx, reading, { principal }) {
JSON.stringify(reading), hash, principal, new Date(ctx.now()).toISOString());
if (row) return { record: present(row), replayed: false };
const prev = await db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(reading.idempotency_key);
-- if (prev.reading_hash !== hash) {
-- fail(409, 'billing.usage_key_reused', `a different reading is already stored under idempotency_key ${reading.idempotency_key}`);
++ const stored = typeof prev.reading === 'string' ? JSON.parse(prev.reading) : prev.reading;
++ if (!moneyFieldsEqual(stored, reading)) {
++ fail(409, 'billing.usage_key_reused', `a reading with different money fields is already stored under idempotency_key ${reading.idempotency_key}`);
}
return { record: present(prev), replayed: true };
}
@@@ -94,4 +112,4 @@ async function list(db, q = {}) {
return { records: page.map(present), next_cursor: next };
}
--module.exports = { record, list, present, CONTRACT };
++module.exports = { record, list, present, moneyFieldsEqual, MONEY_FIELDS, CONTRACT };
ddiff --git a/test/rating.test.js b/test/rating.test.js
iindex faa0239..ececd21 100644
---- a/test/rating.test.js
++++ b/test/rating.test.js
@@@ -284,6 +284,24 @@ let n = 0;
await t.assertReconciled('end');
});
++ await check('a reading replayed three times (new trace_id) and swept twice is charged once: one usage txn under usage:<key>', async () => {
++ const gina = t.user(7);
++ await fund(t, gina.id, 1000);
++ const body = reading(gina, { trace_id: '0af7651916cd43dd8448eb211c80319c' });
++ const rec = await store(body);
++ for (let i = 0; i < 3; i++) {
++ const r = await t.call('POST', '/api/v1/usage', { body: { ...body, trace_id: `${i}bf92f3577b34da6a3ce929d0e0e4736` }, cap: REC, key: null, sub: 'svc:media' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.record.id, rec.id);
++ }
++ await sweep();
++ await sweep();
++ assert.strictEqual(await usageTxns(rec.idempotency_key), 1);
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE type = 'usage' AND idempotency_key LIKE ?").get(`usage:${rec.idempotency_key}%`)).n, 1);
++ assert.strictEqual(await credit(gina), 970);
++ await t.assertReconciled('after replays');
++ });
++
await t.close();
done();
})();
ddiff --git a/test/usage.test.js b/test/usage.test.js
iindex 214ea38..6603094 100644
---- a/test/usage.test.js
++++ b/test/usage.test.js
@@@ -103,6 +103,29 @@ async function userAccessToken(t) {
assert.strictEqual(rows[0].reading.quantity, 1.5);
});
++ await check('a replay compares the money fields only: placement/correlation changes are 200 and change nothing; money changes are 409', async () => {
++ const body = reading({ node: 'node-1', region: 'us-west', route_epoch: 7, trace_id: '0af7651916cd43dd8448eb211c80319c' });
++ const first = await post(body);
++ assert.strictEqual(first.status, 201, first.text);
++ const stored = async () => (await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').all(body.idempotency_key));
++ for (const over of [{ trace_id: '4bf92f3577b34da6a3ce929d0e0e4736' }, { route_epoch: 8 }, { node: 'node-2' }, { region: 'eu-west' }, { source: 'media.retry' },
++ { at: '2026-09-30T10:00:00.000Z' }, { at: '2026-09-30T12:00:00+02:00' }]) {
++ const r = await post({ ...body, ...over });
++ assert.strictEqual(r.status, 200, `${JSON.stringify(over)} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.headers.get('idempotent-replayed'), 'true');
++ assert.deepStrictEqual(r.json.record, first.json.record, JSON.stringify(over));
++ }
++ for (const over of [{ quantity: 2 }, { subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPR' }, { project: PRJ2 }, { at: '2026-09-30T10:00:01Z' }, { unit: 'MiB' },
++ { service: 'bot' }, { resource: 'object-2' }, { provider: 'other' }, { operation: 'upload' }, { project: undefined }]) {
++ const r = await post({ ...body, ...over });
++ assert.strictEqual(r.status, 409, `${JSON.stringify(over)} → ${r.status} ${r.text}`);
++ assert.strictEqual(r.json.code, 'billing.usage_key_reused');
++ }
++ const rows = await stored();
++ assert.strictEqual(rows.length, 1);
++ assert.deepStrictEqual(rows[0].reading, body, 'the first stored reading wins');
++ });
++
await check('service tokens with billing.usage.record only: no token 401, a user token 401, another capability 403', async () => {
assert.strictEqual((await post(reading(), { token: null })).status, 401);
const user = await userAccessToken(t);