Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ BASE_URL=http://localhost:3001
"serviceId": "embedding-v1",
"requests": 10,
"priceStroops": 25,
"billedStroops": 250
"billedStroops": "250"
}
```

Expand All @@ -210,7 +210,7 @@ BASE_URL=http://localhost:3001
"serviceId": "embedding-v1",
"requests": 10,
"priceStroops": 25,
"billedStroops": 250
"billedStroops": "250"
}
```

Expand Down
22 changes: 13 additions & 9 deletions docs/billing-units.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,13 @@ bill is `2,000,000` stroops, or `0.2 XLM`.
Stellar amounts are precise to seven decimal places. Keeping backend prices and
bills in integer stroops avoids floating-point rounding when usage counters are
multiplied by prices. API consumers should treat `priceStroops`,
`billedStroops`, and `totalStroops` as integer ledger units and convert to XLM
only for display.
`billedStroops`, `totalStroops`, and `disabledStroops` as integer ledger units
and convert to XLM only for display.

The current implementation stores counters and billing values as JavaScript
numbers. `POST /api/v1/usage` clamps request counters at
`Number.MAX_SAFE_INTEGER`. Future bigint-backed precision work should preserve
the public stroops convention while avoiding number precision limits for very
large counters and bills.
`POST /api/v1/usage` keeps request counters as JSON numbers and clamps them at
`Number.MAX_SAFE_INTEGER`. Billing amounts are computed with `BigInt` and
serialized as decimal strings so values above JavaScript's safe integer range
remain exact in JSON responses.

## Endpoint Semantics

Expand All @@ -40,14 +39,16 @@ Returns the current quote for one agent and service pair:

- `requests`: outstanding recorded requests.
- `priceStroops`: service price per request.
- `billedStroops`: `requests * priceStroops`.
- `billedStroops`: `requests * priceStroops`, serialized as a decimal string.

This endpoint is read-only and does not change counters or transfer funds.

### `GET /api/v1/billing/total`

Returns `totalStroops`, the sum of all outstanding usage counters multiplied by
their service prices. This is also read-only and does not transfer funds.
their service prices, serialized as a decimal string. Disabled priced usage is
also returned as decimal-string `disabledStroops`. This is read-only and does
not transfer funds.

### `POST /api/v1/settle`

Expand All @@ -59,6 +60,9 @@ The backend settle endpoint is an off-chain accounting operation. It:
4. Returns `{ agent, serviceId, requests, priceStroops, billedStroops }`.
5. Records a `usage.settled` audit event.

`billedStroops` is a decimal string in the response. This is intentionally a
breaking change from numeric JSON amounts to avoid silent precision loss.

It does **not** move XLM, tokens, or any other on-chain value. A successful
response means the backend drained its in-memory accumulator and quoted the
amount that should be settled elsewhere.
Expand Down
78 changes: 78 additions & 0 deletions src/billing-stroops.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import express from "express";
import request from "supertest";
import { createUsageRouter } from "./routes/usage.js";
import { servicesDisabled, servicesStore, usageStore } from "./store/state.js";

function createIsolatedBillingApp() {
const app = express();
app.use(express.json());
app.use(createUsageRouter());
return app;
}

beforeEach(() => {
servicesDisabled.clear();
servicesStore.clear();
usageStore.clear();
});

void describe("BigInt stroops billing responses", () => {
void it("returns exact decimal-string billedStroops for pair billing", async () => {
const app = createIsolatedBillingApp();
servicesStore.set("svc-expensive", { priceStroops: 10_000_000 });
usageStore.set("agent-big::svc-expensive", Number.MAX_SAFE_INTEGER);

const res = await request(app).get("/api/v1/billing/agent-big/svc-expensive");

assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.requests, Number.MAX_SAFE_INTEGER);
assert.strictEqual(res.body.priceStroops, 10_000_000);
assert.strictEqual(res.body.billedStroops, "90071992547409910000000");
});

void it("returns exact decimal-string billedStroops from settle and drains usage", async () => {
const app = createIsolatedBillingApp();
servicesStore.set("svc-settle", { priceStroops: 9_999_999 });
usageStore.set("agent-settle::svc-settle", Number.MAX_SAFE_INTEGER);

const res = await request(app)
.post("/api/v1/settle")
.send({ agent: "agent-settle", serviceId: "svc-settle" });

assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.billedStroops, "90071983540210655259009");
assert.strictEqual(usageStore.get("agent-settle::svc-settle"), 0);
});

void it("returns exact decimal-string totals across priced and disabled usage", async () => {
const app = createIsolatedBillingApp();
servicesStore.set("svc-enabled", { priceStroops: 10_000_000 });
servicesStore.set("svc-disabled", { priceStroops: 2 });
servicesDisabled.add("svc-disabled");
usageStore.set("agent-a::svc-enabled", Number.MAX_SAFE_INTEGER);
usageStore.set("agent-b::svc-disabled", Number.MAX_SAFE_INTEGER);
usageStore.set("agent-c::svc-deleted", 7);

const res = await request(app).get("/api/v1/billing/total");

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(res.body, {
totalStroops: "90072010561808419481982",
disabledStroops: "18014398509481982",
unpricedRequests: 7,
});
});

void it("keeps zero-price service billing as a string zero", async () => {
const app = createIsolatedBillingApp();
servicesStore.set("svc-free", { priceStroops: 0 });
usageStore.set("agent-free::svc-free", Number.MAX_SAFE_INTEGER);

const res = await request(app).get("/api/v1/billing/agent-free/svc-free");

assert.strictEqual(res.status, 200);
assert.strictEqual(res.body.billedStroops, "0");
});
});
16 changes: 8 additions & 8 deletions src/billing-total-breakdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ void describe("billing total breakdown", () => {

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(res.body, {
totalStroops: 0,
disabledStroops: 0,
totalStroops: "0",
disabledStroops: "0",
unpricedRequests: 0,
});
});
Expand All @@ -43,8 +43,8 @@ void describe("billing total breakdown", () => {

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(res.body, {
totalStroops: 130,
disabledStroops: 0,
totalStroops: "130",
disabledStroops: "0",
unpricedRequests: 0,
});
});
Expand All @@ -61,8 +61,8 @@ void describe("billing total breakdown", () => {

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(res.body, {
totalStroops: 101,
disabledStroops: 66,
totalStroops: "101",
disabledStroops: "66",
unpricedRequests: 0,
});
});
Expand All @@ -80,8 +80,8 @@ void describe("billing total breakdown", () => {

assert.strictEqual(res.status, 200);
assert.deepStrictEqual(res.body, {
totalStroops: 62,
disabledStroops: 10,
totalStroops: "62",
disabledStroops: "10",
unpricedRequests: 8,
});
});
Expand Down
4 changes: 2 additions & 2 deletions src/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ void describe("AgentPay Backend", () => {
assert.strictEqual(quote.status, 200);
assert.strictEqual(quote.body.requests, 10);
assert.strictEqual(quote.body.priceStroops, 50);
assert.strictEqual(quote.body.billedStroops, 500);
assert.strictEqual(quote.body.billedStroops, "500");

const settle = await request(app)
.post("/api/v1/settle")
.send({ agent: "agent-bill", serviceId: "svc-bill" });
assert.strictEqual(settle.body.billedStroops, 500);
assert.strictEqual(settle.body.billedStroops, "500");

const after = await request(app).get("/api/v1/usage/agent-bill/svc-bill");
assert.strictEqual(after.body.total, 0);
Expand Down
96 changes: 94 additions & 2 deletions src/routes/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function createMetaRouter(): Router {
"Admin pause/unpause, API keys, webhooks, event log.",
"Bulk usage + bulk services, CSV/JSON exports.",
"Metadata + disabled flag per service.",
"Billing stroop amounts are returned as exact decimal strings.",
],
},
],
Expand Down Expand Up @@ -86,8 +87,54 @@ export function createMetaRouter(): Router {
"/api/v1/usage": { post: { summary: "Record usage" } },
"/api/v1/usage/bulk": { post: { summary: "Batched record" } },
"/api/v1/usage/{agent}/{serviceId}": { get: { summary: "Read accumulator" } },
"/api/v1/billing/{agent}/{serviceId}": { get: { summary: "Quote bill" } },
"/api/v1/settle": { post: { summary: "Drain & quote bill" } },
"/api/v1/billing/total": {
get: {
summary: "Quote protocol-wide outstanding bill",
responses: {
"200": {
description:
"Billing totals with decimal-string stroop amounts for exact JSON precision.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/BillingTotal" },
},
},
},
},
},
},
"/api/v1/billing/{agent}/{serviceId}": {
get: {
summary: "Quote bill",
responses: {
"200": {
description:
"Pair billing quote with billedStroops serialized as a decimal string.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/BillingQuote" },
},
},
},
},
},
},
"/api/v1/settle": {
post: {
summary: "Drain & quote bill",
responses: {
"200": {
description:
"Settlement quote with billedStroops serialized as a decimal string.",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/BillingQuote" },
},
},
},
},
},
},
"/api/v1/api-keys": {
get: { summary: "List api keys" },
post: { summary: "Create api key" },
Expand All @@ -102,6 +149,51 @@ export function createMetaRouter(): Router {
"/api/v1/admin/unpause": { post: { summary: "Resume" } },
"/api/v1/admin/status": { get: { summary: "Read pause flag" } },
},
components: {
schemas: {
BillingQuote: {
type: "object",
properties: {
agent: { type: "string" },
serviceId: { type: "string" },
requests: { type: "integer", minimum: 0 },
priceStroops: { type: "integer", minimum: 0 },
billedStroops: {
type: "string",
pattern: "^[0-9]+$",
description:
"Exact decimal stroop amount. String-typed to avoid JSON number precision loss.",
},
},
required: [
"agent",
"serviceId",
"requests",
"priceStroops",
"billedStroops",
],
},
BillingTotal: {
type: "object",
properties: {
totalStroops: {
type: "string",
pattern: "^[0-9]+$",
description:
"Exact decimal stroop total. String-typed to avoid JSON number precision loss.",
},
disabledStroops: {
type: "string",
pattern: "^[0-9]+$",
description:
"Exact decimal stroop total for disabled services included in billing totals.",
},
unpricedRequests: { type: "integer", minimum: 0 },
},
required: ["totalStroops", "disabledStroops", "unpricedRequests"],
},
},
},
});
});

Expand Down
8 changes: 8 additions & 0 deletions src/routes/operational.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ void describe("operational routes", () => {
const openapi = await request(app).get("/api/v1/openapi.json");
assert.strictEqual(openapi.status, 200);
assert.ok(openapi.body.paths["/api/v1/usage"]);
assert.strictEqual(
openapi.body.components.schemas.BillingQuote.properties.billedStroops.type,
"string"
);
assert.strictEqual(
openapi.body.components.schemas.BillingTotal.properties.totalStroops.type,
"string"
);
});

void it("creates, lists, and revokes API keys without exposing full keys on list", async () => {
Expand Down
6 changes: 3 additions & 3 deletions src/routes/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ void describe("usage router", () => {
assert.strictEqual(quote.status, 200);
assert.strictEqual(quote.body.requests, 4);
assert.strictEqual(quote.body.priceStroops, 25);
assert.strictEqual(quote.body.billedStroops, 100);
assert.strictEqual(quote.body.billedStroops, "100");
});

void it("covers bulk usage, exports, agent rollups, settlement, and disabled services", async () => {
Expand Down Expand Up @@ -87,7 +87,7 @@ void describe("usage router", () => {

const billingTotal = await request(app).get("/api/v1/billing/total");
assert.strictEqual(billingTotal.status, 200);
assert.strictEqual(billingTotal.body.totalStroops, 50);
assert.strictEqual(billingTotal.body.totalStroops, "50");

const agents = await request(app).get("/api/v1/agents");
assert.strictEqual(agents.status, 200);
Expand All @@ -108,7 +108,7 @@ void describe("usage router", () => {
.post("/api/v1/settle")
.send({ agent: "agent-bulk", serviceId: "svc-bulk" });
assert.strictEqual(settled.status, 200);
assert.strictEqual(settled.body.billedStroops, 50);
assert.strictEqual(settled.body.billedStroops, "50");

servicesDisabled.add("svc-disabled");
const disabled = await request(app)
Expand Down
Loading