@absolutejs/billing 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ This file is generated by `absolute-changelog` from the entries in
6
6
  `changelog/`. Edit an entry, not this file — and add new ones under
7
7
  `changelog/unreleased/`.
8
8
 
9
+ ## 0.9.0 — 2026-09-11
10
+
11
+ ### Added
12
+
13
+ - **Add bounded customer billing reports, receipt cursors and reconciled usage projections**
14
+
15
+ ## 0.8.0 — 2026-09-11
16
+
17
+ ### Added
18
+
19
+ - **Add single-use purchase-only checkout handoffs with hashed capabilities, account binding and CSRF verification**
20
+
9
21
  ## 0.7.0 — 2026-09-11
10
22
 
11
23
  ### Added
package/README.md CHANGED
@@ -98,3 +98,9 @@ BSL-1.1 with named carveout against hosted SaaS billing platforms
98
98
  ## Prepaid service credits
99
99
 
100
100
  [Durable balances, reservations, capped work, and migration rules](docs/prepaid-credits.md) are available through the `prepaid`, `prepaid-postgres`, and `credit-work` subpaths.
101
+
102
+ ## Secure credit checkout
103
+
104
+ [Purchase-only handoffs](docs/checkout-handoffs.md) provide expiring, single-use browser capabilities without a full login session.
105
+
106
+ `@absolutejs/billing/reports` provides customer-facing status, receipt pagination, and usage contracts. `parseUsageRange` uses a half-open UTC date interval of at most 90 days (default: the last 30 days including today). Receipt cursors are positions, never authorization: bind every query, reversal join, and cursor to the caller's account. Readers must project through the public helpers, which omit provider costs, payment tokens and vault references. Usage day and feature totals must reconcile exactly; purchased dollars and consumed service credits are different measures. Automatic refill is currently unsupported.
package/changelog.json CHANGED
@@ -1,16 +1,36 @@
1
1
  {
2
- "contract": 1,
3
- "name": "@absolutejs/billing",
4
- "releases": [
5
- {
6
- "changes": [
7
- {
8
- "kind": "added",
9
- "summary": "Add durable prepaid credit accounts, reservations, and capped idempotent work settlement"
10
- }
11
- ],
12
- "date": "2026-09-11",
13
- "version": "0.7.0"
14
- }
15
- ]
2
+ "contract": 1,
3
+ "name": "@absolutejs/billing",
4
+ "releases": [
5
+ {
6
+ "version": "0.9.0",
7
+ "date": "2026-09-11",
8
+ "changes": [
9
+ {
10
+ "kind": "added",
11
+ "summary": "Add bounded customer billing reports, receipt cursors and reconciled usage projections"
12
+ }
13
+ ]
14
+ },
15
+ {
16
+ "version": "0.8.0",
17
+ "date": "2026-09-11",
18
+ "changes": [
19
+ {
20
+ "kind": "added",
21
+ "summary": "Add single-use purchase-only checkout handoffs with hashed capabilities, account binding and CSRF verification"
22
+ }
23
+ ]
24
+ },
25
+ {
26
+ "changes": [
27
+ {
28
+ "kind": "added",
29
+ "summary": "Add durable prepaid credit accounts, reservations, and capped idempotent work settlement"
30
+ }
31
+ ],
32
+ "date": "2026-09-11",
33
+ "version": "0.7.0"
34
+ }
35
+ ]
16
36
  }
@@ -0,0 +1,39 @@
1
+ import type { CreditSqlClient } from "./prepaidPostgres";
2
+ export type CheckoutQuote = {
3
+ productId: string;
4
+ amountCents: number;
5
+ currency: string;
6
+ credits: number;
7
+ };
8
+ export type CheckoutHandoff = {
9
+ id: string;
10
+ accountId: string;
11
+ quote: CheckoutQuote;
12
+ expiresAt: number;
13
+ };
14
+ /** Apply explicitly during deployment. Secrets are stored only as SHA-256 hashes. */
15
+ export declare const checkoutHandoffPostgresSchemaSql: () => string;
16
+ /** This is a purchase-only capability, never a login or a saved-card authorization.
17
+ * The application binds the authenticated account and prices the quote. */
18
+ export declare const createPostgresCheckoutHandoffs: (db: CreditSqlClient, now?: () => number) => {
19
+ issue(accountId: string, quote: CheckoutQuote): Promise<{
20
+ id: `${string}-${string}-${string}-${string}-${string}`;
21
+ code: string;
22
+ expiresAt: number;
23
+ }>;
24
+ /** Invoke only on a deliberate same-origin POST, never a preview GET.
25
+ * Atomic exchange makes concurrent redemption and link replay fail closed. */
26
+ exchange(code: string, browserAccountId?: string | null): Promise<{
27
+ handoff: CheckoutHandoff;
28
+ session: string;
29
+ csrf: string;
30
+ expiresAt: number;
31
+ } | null>;
32
+ authorize(session: string, csrf: string, browserAccountId?: string | null): Promise<CheckoutHandoff | null>;
33
+ /** Public IDs are not capabilities: status always requires account binding. */
34
+ get(accountId: string, id: string): Promise<CheckoutHandoff | null>;
35
+ };
36
+ /** Put the code in a fragment so previews, servers and referrers don't receive it.
37
+ * The landing page must remove it before loading any third-party code. */
38
+ export declare const checkoutHandoffUrl: (landingUrl: string, code: string) => string;
39
+ export declare const checkoutSameOrigin: (request: Request, origin: string) => boolean;
@@ -0,0 +1,108 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/checkoutHandoff.ts
18
+ import { createHash, randomBytes, randomUUID } from "crypto";
19
+ var lifetimeMs = 15 * 60 * 1000;
20
+ var hash = (secret) => createHash("sha256").update(secret).digest("hex");
21
+ var secret = () => randomBytes(32).toString("base64url");
22
+ var validSecret = (value) => /^[A-Za-z0-9_-]{43}$/.test(value);
23
+ var validQuote = (value) => typeof value.productId === "string" && value.productId.length > 0 && value.productId.length <= 128 && Number.isSafeInteger(value.amountCents) && value.amountCents > 0 && /^[A-Z]{3}$/.test(value.currency) && Number.isSafeInteger(value.credits) && value.credits > 0;
24
+ var checkoutHandoffPostgresSchemaSql = () => `
25
+ CREATE SCHEMA IF NOT EXISTS billing_checkout;
26
+ CREATE TABLE IF NOT EXISTS billing_checkout.handoffs (
27
+ id text PRIMARY KEY, account_id text NOT NULL, quote jsonb NOT NULL,
28
+ code_hash text NOT NULL UNIQUE, expires_at bigint NOT NULL,
29
+ session_hash text UNIQUE, csrf_hash text, session_expires_at bigint
30
+ );
31
+ CREATE INDEX IF NOT EXISTS handoffs_account ON billing_checkout.handoffs(account_id, id);
32
+ `;
33
+ var decode = (row) => {
34
+ if (!row)
35
+ return null;
36
+ const quote = row.quote;
37
+ if (!quote || typeof quote !== "object" || !("productId" in quote) || !("amountCents" in quote) || !("currency" in quote) || !("credits" in quote) || typeof quote.productId !== "string" || typeof quote.amountCents !== "number" || typeof quote.currency !== "string" || typeof quote.credits !== "number" || typeof row.id !== "string" || typeof row.account_id !== "string")
38
+ throw new Error("Invalid checkout record");
39
+ const parsed = {
40
+ productId: quote.productId,
41
+ amountCents: quote.amountCents,
42
+ currency: quote.currency,
43
+ credits: quote.credits
44
+ };
45
+ if (!validQuote(parsed))
46
+ throw new Error("Invalid checkout quote");
47
+ return {
48
+ id: row.id,
49
+ accountId: row.account_id,
50
+ quote: parsed,
51
+ expiresAt: Number(row.expires_at)
52
+ };
53
+ };
54
+ var createPostgresCheckoutHandoffs = (db, now = Date.now) => ({
55
+ async issue(accountId, quote) {
56
+ if (!accountId || !validQuote(quote))
57
+ throw new Error("Invalid checkout request");
58
+ const code = secret();
59
+ const id = randomUUID();
60
+ const expiresAt = now() + lifetimeMs;
61
+ await db.query(`INSERT INTO billing_checkout.handoffs(id,account_id,quote,code_hash,expires_at) VALUES ($1,$2,$3::jsonb,$4,$5)`, [id, accountId, JSON.stringify(quote), hash(code), expiresAt]);
62
+ return { id, code, expiresAt };
63
+ },
64
+ async exchange(code, browserAccountId = null) {
65
+ if (!validSecret(code))
66
+ return null;
67
+ const session = secret();
68
+ const csrf = secret();
69
+ const expiresAt = now() + lifetimeMs;
70
+ const { rows } = await db.query(`UPDATE billing_checkout.handoffs SET session_hash=$2,csrf_hash=$3,session_expires_at=$4 WHERE code_hash=$1 AND session_hash IS NULL AND expires_at>$5 AND ($6::text IS NULL OR account_id=$6) RETURNING *`, [
71
+ hash(code),
72
+ hash(session),
73
+ hash(csrf),
74
+ expiresAt,
75
+ now(),
76
+ browserAccountId
77
+ ]);
78
+ const handoff = decode(rows[0]);
79
+ return handoff ? { handoff, session, csrf, expiresAt } : null;
80
+ },
81
+ async authorize(session, csrf, browserAccountId = null) {
82
+ if (!validSecret(session) || !validSecret(csrf))
83
+ return null;
84
+ const { rows } = await db.query(`SELECT * FROM billing_checkout.handoffs WHERE session_hash=$1 AND csrf_hash=$2 AND session_expires_at>$3 AND ($4::text IS NULL OR account_id=$4)`, [hash(session), hash(csrf), now(), browserAccountId]);
85
+ return decode(rows[0]);
86
+ },
87
+ async get(accountId, id) {
88
+ const { rows } = await db.query(`SELECT * FROM billing_checkout.handoffs WHERE id=$1 AND account_id=$2`, [id, accountId]);
89
+ return decode(rows[0]);
90
+ }
91
+ });
92
+ var checkoutHandoffUrl = (landingUrl, code) => {
93
+ const url = new URL(landingUrl);
94
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !validSecret(code))
95
+ throw new Error("Invalid checkout landing URL");
96
+ url.hash = code;
97
+ return url.href;
98
+ };
99
+ var checkoutSameOrigin = (request, origin) => request.method === "POST" && request.headers.get("origin") === new URL(origin).origin && [null, "same-origin"].includes(request.headers.get("sec-fetch-site"));
100
+ export {
101
+ checkoutHandoffPostgresSchemaSql,
102
+ checkoutHandoffUrl,
103
+ checkoutSameOrigin,
104
+ createPostgresCheckoutHandoffs
105
+ };
106
+
107
+ //# debugId=F96D1D9F1E1CA67A64756E2164756E21
108
+ //# sourceMappingURL=checkoutHandoff.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/checkoutHandoff.ts"],
4
+ "sourcesContent": [
5
+ "import { createHash, randomBytes, randomUUID } from \"node:crypto\";\nimport type { CreditSqlClient } from \"./prepaidPostgres\";\n\nexport type CheckoutQuote = {\n productId: string;\n amountCents: number;\n currency: string;\n credits: number;\n};\nexport type CheckoutHandoff = {\n id: string;\n accountId: string;\n quote: CheckoutQuote;\n expiresAt: number;\n};\nconst lifetimeMs = 15 * 60 * 1000;\nconst hash = (secret: string) =>\n createHash(\"sha256\").update(secret).digest(\"hex\");\nconst secret = () => randomBytes(32).toString(\"base64url\");\nconst validSecret = (value: string) => /^[A-Za-z0-9_-]{43}$/.test(value);\nconst validQuote = (value: CheckoutQuote) =>\n typeof value.productId === \"string\" &&\n value.productId.length > 0 &&\n value.productId.length <= 128 &&\n Number.isSafeInteger(value.amountCents) &&\n value.amountCents > 0 &&\n /^[A-Z]{3}$/.test(value.currency) &&\n Number.isSafeInteger(value.credits) &&\n value.credits > 0;\n\n/** Apply explicitly during deployment. Secrets are stored only as SHA-256 hashes. */\nexport const checkoutHandoffPostgresSchemaSql = () => `\nCREATE SCHEMA IF NOT EXISTS billing_checkout;\nCREATE TABLE IF NOT EXISTS billing_checkout.handoffs (\n id text PRIMARY KEY, account_id text NOT NULL, quote jsonb NOT NULL,\n code_hash text NOT NULL UNIQUE, expires_at bigint NOT NULL,\n session_hash text UNIQUE, csrf_hash text, session_expires_at bigint\n);\nCREATE INDEX IF NOT EXISTS handoffs_account ON billing_checkout.handoffs(account_id, id);\n`;\nconst decode = (\n row: Record<string, unknown> | undefined,\n): CheckoutHandoff | null => {\n if (!row) return null;\n const quote = row.quote;\n if (\n !quote ||\n typeof quote !== \"object\" ||\n !(\"productId\" in quote) ||\n !(\"amountCents\" in quote) ||\n !(\"currency\" in quote) ||\n !(\"credits\" in quote) ||\n typeof quote.productId !== \"string\" ||\n typeof quote.amountCents !== \"number\" ||\n typeof quote.currency !== \"string\" ||\n typeof quote.credits !== \"number\" ||\n typeof row.id !== \"string\" ||\n typeof row.account_id !== \"string\"\n )\n throw new Error(\"Invalid checkout record\");\n const parsed = {\n productId: quote.productId,\n amountCents: quote.amountCents,\n currency: quote.currency,\n credits: quote.credits,\n };\n if (!validQuote(parsed)) throw new Error(\"Invalid checkout quote\");\n return {\n id: row.id,\n accountId: row.account_id,\n quote: parsed,\n expiresAt: Number(row.expires_at),\n };\n};\n/** This is a purchase-only capability, never a login or a saved-card authorization.\n * The application binds the authenticated account and prices the quote. */\nexport const createPostgresCheckoutHandoffs = (\n db: CreditSqlClient,\n now = Date.now,\n) => ({\n async issue(accountId: string, quote: CheckoutQuote) {\n if (!accountId || !validQuote(quote))\n throw new Error(\"Invalid checkout request\");\n const code = secret();\n const id = randomUUID();\n const expiresAt = now() + lifetimeMs;\n await db.query(\n `INSERT INTO billing_checkout.handoffs(id,account_id,quote,code_hash,expires_at) VALUES ($1,$2,$3::jsonb,$4,$5)`,\n [id, accountId, JSON.stringify(quote), hash(code), expiresAt],\n );\n return { id, code, expiresAt };\n },\n /** Invoke only on a deliberate same-origin POST, never a preview GET.\n * Atomic exchange makes concurrent redemption and link replay fail closed. */\n async exchange(code: string, browserAccountId: string | null = null) {\n if (!validSecret(code)) return null;\n const session = secret();\n const csrf = secret();\n const expiresAt = now() + lifetimeMs;\n const { rows } = await db.query(\n `UPDATE billing_checkout.handoffs SET session_hash=$2,csrf_hash=$3,session_expires_at=$4 WHERE code_hash=$1 AND session_hash IS NULL AND expires_at>$5 AND ($6::text IS NULL OR account_id=$6) RETURNING *`,\n [\n hash(code),\n hash(session),\n hash(csrf),\n expiresAt,\n now(),\n browserAccountId,\n ],\n );\n const handoff = decode(rows[0]);\n return handoff ? { handoff, session, csrf, expiresAt } : null;\n },\n async authorize(\n session: string,\n csrf: string,\n browserAccountId: string | null = null,\n ) {\n if (!validSecret(session) || !validSecret(csrf)) return null;\n const { rows } = await db.query(\n `SELECT * FROM billing_checkout.handoffs WHERE session_hash=$1 AND csrf_hash=$2 AND session_expires_at>$3 AND ($4::text IS NULL OR account_id=$4)`,\n [hash(session), hash(csrf), now(), browserAccountId],\n );\n return decode(rows[0]);\n },\n /** Public IDs are not capabilities: status always requires account binding. */\n async get(accountId: string, id: string) {\n const { rows } = await db.query(\n `SELECT * FROM billing_checkout.handoffs WHERE id=$1 AND account_id=$2`,\n [id, accountId],\n );\n return decode(rows[0]);\n },\n});\n\n/** Put the code in a fragment so previews, servers and referrers don't receive it.\n * The landing page must remove it before loading any third-party code. */\nexport const checkoutHandoffUrl = (landingUrl: string, code: string) => {\n const url = new URL(landingUrl);\n if (\n url.protocol !== \"https:\" ||\n url.username ||\n url.password ||\n url.search ||\n url.hash ||\n !validSecret(code)\n )\n throw new Error(\"Invalid checkout landing URL\");\n url.hash = code;\n return url.href;\n};\nexport const checkoutSameOrigin = (request: Request, origin: string) =>\n request.method === \"POST\" &&\n request.headers.get(\"origin\") === new URL(origin).origin &&\n [null, \"same-origin\"].includes(request.headers.get(\"sec-fetch-site\"));\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "F96D1D9F1E1CA67A64756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,65 @@
1
+ /** Public customer reports. No payment tokens, vault references, or provider costs. */
2
+ export type BillingStatus = {
3
+ portalAccess: boolean;
4
+ subscription: {
5
+ status: string;
6
+ renewsAt: string | null;
7
+ cancelAtPeriodEnd: boolean;
8
+ } | null;
9
+ credits: {
10
+ remaining: number;
11
+ reserved: number;
12
+ purchased: number;
13
+ promotional: number;
14
+ debt: number;
15
+ };
16
+ automaticRefill: false;
17
+ };
18
+ export type BillingReceipt = {
19
+ id: string;
20
+ issuedAt: string;
21
+ currency: string;
22
+ amountCents: number;
23
+ refundedAmountCents: number;
24
+ source: "credit_purchase" | "initial" | "plan_change" | "renewal";
25
+ status: "paid" | "partially_refunded" | "refunded";
26
+ };
27
+ export type ReceiptCursor = {
28
+ at: string;
29
+ id: string;
30
+ };
31
+ export type ReceiptPageRequest = {
32
+ limit: number;
33
+ cursor: ReceiptCursor | null;
34
+ };
35
+ export type ReceiptPage = {
36
+ receipts: BillingReceipt[];
37
+ nextCursor: string | null;
38
+ };
39
+ export type UsageRange = {
40
+ from: string;
41
+ to: string;
42
+ };
43
+ export type CustomerUsageReport = {
44
+ from: string;
45
+ to: string;
46
+ creditsConsumed: number;
47
+ events: number;
48
+ byDay: {
49
+ day: string;
50
+ credits: number;
51
+ events: number;
52
+ }[];
53
+ byFeature: {
54
+ feature: string;
55
+ credits: number;
56
+ events: number;
57
+ }[];
58
+ };
59
+ export declare const parseUsageRange: (input: unknown, now?: Date) => UsageRange;
60
+ /** Cursor is a position, never authorization. Consumers must filter by account. */
61
+ export declare const encodeReceiptCursor: (cursor: ReceiptCursor) => string;
62
+ export declare const parseReceiptPage: (input: unknown) => ReceiptPageRequest;
63
+ export declare const projectBillingStatus: (value: BillingStatus) => BillingStatus;
64
+ export declare const projectReceiptPage: (value: ReceiptPage, limit: number) => ReceiptPage;
65
+ export declare const projectUsageReport: (value: CustomerUsageReport, range: UsageRange) => CustomerUsageReport;
@@ -0,0 +1,146 @@
1
+ // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+
17
+ // src/reports.ts
18
+ var DAY_MS = 86400000;
19
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ var record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
21
+ var only = (input, keys) => {
22
+ if (!record(input) || Object.keys(input).some((key) => !keys.includes(key)))
23
+ throw new Error("Invalid report input");
24
+ return input;
25
+ };
26
+ var integer = (value) => {
27
+ if (!Number.isSafeInteger(value) || value < 0)
28
+ throw new Error("Invalid report amount");
29
+ return value;
30
+ };
31
+ var iso = (value) => {
32
+ if (!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value)
33
+ throw new Error("Invalid report timestamp");
34
+ return value;
35
+ };
36
+ var day = (value) => {
37
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value) || new Date(value + "T00:00:00.000Z").toISOString().slice(0, 10) !== value)
38
+ throw new Error("Expected a UTC calendar date");
39
+ return value;
40
+ };
41
+ var parseUsageRange = (input, now = new Date) => {
42
+ const args = only(input, ["from", "to"]);
43
+ const tomorrow = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + DAY_MS);
44
+ const to = day(args.to ?? tomorrow.toISOString().slice(0, 10));
45
+ const from = day(args.from ?? new Date(Date.parse(to) - 30 * DAY_MS).toISOString().slice(0, 10));
46
+ const duration = Date.parse(to) - Date.parse(from);
47
+ if (duration <= 0 || duration > 90 * DAY_MS || Date.parse(to) > tomorrow.getTime())
48
+ throw new Error("Choose a range of 1\u201390 UTC days, ending no later than tomorrow");
49
+ return { from, to };
50
+ };
51
+ var encodeReceiptCursor = (cursor) => {
52
+ if (!UUID.test(cursor.id))
53
+ throw new Error("Invalid receipt cursor");
54
+ return Buffer.from(JSON.stringify({ at: iso(cursor.at), id: cursor.id })).toString("base64url");
55
+ };
56
+ var parseReceiptPage = (input) => {
57
+ const args = only(input, ["limit", "cursor"]);
58
+ const limit = args.limit ?? 20;
59
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 50)
60
+ throw new Error("Receipt limit must be 1\u201350");
61
+ if (args.cursor === undefined || args.cursor === null)
62
+ return { limit, cursor: null };
63
+ if (typeof args.cursor !== "string" || args.cursor.length > 256 || !/^[A-Za-z0-9_-]+$/.test(args.cursor))
64
+ throw new Error("Invalid receipt cursor");
65
+ const decoded = JSON.parse(Buffer.from(args.cursor, "base64url").toString("utf8"));
66
+ if (!record(decoded) || typeof decoded.at !== "string" || typeof decoded.id !== "string" || !UUID.test(decoded.id))
67
+ throw new Error("Invalid receipt cursor");
68
+ return { limit, cursor: { at: iso(decoded.at), id: decoded.id } };
69
+ };
70
+ var projectBillingStatus = (value) => ({
71
+ portalAccess: value.portalAccess === true,
72
+ subscription: value.subscription ? {
73
+ status: String(value.subscription.status).slice(0, 64),
74
+ renewsAt: value.subscription.renewsAt === null ? null : iso(value.subscription.renewsAt),
75
+ cancelAtPeriodEnd: value.subscription.cancelAtPeriodEnd === true
76
+ } : null,
77
+ credits: {
78
+ remaining: integer(value.credits.remaining),
79
+ reserved: integer(value.credits.reserved),
80
+ purchased: integer(value.credits.purchased),
81
+ promotional: integer(value.credits.promotional),
82
+ debt: integer(value.credits.debt)
83
+ },
84
+ automaticRefill: false
85
+ });
86
+ var projectReceiptPage = (value, limit) => {
87
+ if (value.receipts.length > limit)
88
+ throw new Error("Receipt page exceeds its limit");
89
+ if (value.nextCursor !== null)
90
+ parseReceiptPage({ cursor: value.nextCursor });
91
+ return {
92
+ receipts: value.receipts.map((row) => {
93
+ if (!UUID.test(row.id) || !/^[A-Z]{3}$/.test(row.currency) || !["credit_purchase", "initial", "plan_change", "renewal"].includes(row.source) || !["paid", "partially_refunded", "refunded"].includes(row.status) || row.refundedAmountCents > row.amountCents)
94
+ throw new Error("Invalid receipt");
95
+ return {
96
+ id: row.id,
97
+ issuedAt: iso(row.issuedAt),
98
+ currency: row.currency,
99
+ amountCents: integer(row.amountCents),
100
+ refundedAmountCents: integer(row.refundedAmountCents),
101
+ source: row.source,
102
+ status: row.status
103
+ };
104
+ }),
105
+ nextCursor: value.nextCursor
106
+ };
107
+ };
108
+ var projectUsageReport = (value, range) => {
109
+ if (value.from !== range.from || value.to !== range.to || value.byDay.length > 90 || value.byFeature.length > 21)
110
+ throw new Error("Invalid usage report bounds");
111
+ const byDay = value.byDay.map((row) => ({
112
+ day: day(row.day),
113
+ credits: integer(row.credits),
114
+ events: integer(row.events)
115
+ }));
116
+ const byFeature = value.byFeature.map((row) => ({
117
+ feature: row.feature.slice(0, 128),
118
+ credits: integer(row.credits),
119
+ events: integer(row.events)
120
+ }));
121
+ const credits = integer(value.creditsConsumed), events = integer(value.events);
122
+ for (const rows of [byDay, byFeature])
123
+ if (rows.reduce((sum, row) => sum + row.credits, 0) !== credits || rows.reduce((sum, row) => sum + row.events, 0) !== events)
124
+ throw new Error("Usage breakdown does not reconcile");
125
+ if (new Set(byDay.map((row) => row.day)).size !== byDay.length || byDay.some((row) => row.day < range.from || row.day >= range.to))
126
+ throw new Error("Invalid usage days");
127
+ return {
128
+ from: range.from,
129
+ to: range.to,
130
+ creditsConsumed: credits,
131
+ events,
132
+ byDay,
133
+ byFeature
134
+ };
135
+ };
136
+ export {
137
+ encodeReceiptCursor,
138
+ parseReceiptPage,
139
+ parseUsageRange,
140
+ projectBillingStatus,
141
+ projectReceiptPage,
142
+ projectUsageReport
143
+ };
144
+
145
+ //# debugId=1CCF8D983CF6711964756E2164756E21
146
+ //# sourceMappingURL=reports.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/reports.ts"],
4
+ "sourcesContent": [
5
+ "/** Public customer reports. No payment tokens, vault references, or provider costs. */\nexport type BillingStatus = {\n portalAccess: boolean;\n subscription: {\n status: string;\n renewsAt: string | null;\n cancelAtPeriodEnd: boolean;\n } | null;\n credits: {\n remaining: number;\n reserved: number;\n purchased: number;\n promotional: number;\n debt: number;\n };\n automaticRefill: false;\n};\nexport type BillingReceipt = {\n id: string;\n issuedAt: string;\n currency: string;\n amountCents: number;\n refundedAmountCents: number;\n source: \"credit_purchase\" | \"initial\" | \"plan_change\" | \"renewal\";\n status: \"paid\" | \"partially_refunded\" | \"refunded\";\n};\nexport type ReceiptCursor = { at: string; id: string };\nexport type ReceiptPageRequest = {\n limit: number;\n cursor: ReceiptCursor | null;\n};\nexport type ReceiptPage = {\n receipts: BillingReceipt[];\n nextCursor: string | null;\n};\nexport type UsageRange = { from: string; to: string };\nexport type CustomerUsageReport = {\n from: string;\n to: string;\n creditsConsumed: number;\n events: number;\n byDay: { day: string; credits: number; events: number }[];\n byFeature: { feature: string; credits: number; events: number }[];\n};\nconst DAY_MS = 86_400_000;\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\nconst record = (value: unknown): value is Record<string, unknown> =>\n typeof value === \"object\" && value !== null && !Array.isArray(value);\nconst only = (input: unknown, keys: readonly string[]) => {\n if (!record(input) || Object.keys(input).some((key) => !keys.includes(key)))\n throw new Error(\"Invalid report input\");\n return input;\n};\nconst integer = (value: number) => {\n if (!Number.isSafeInteger(value) || value < 0)\n throw new Error(\"Invalid report amount\");\n return value;\n};\nconst iso = (value: string) => {\n if (\n !Number.isFinite(Date.parse(value)) ||\n new Date(value).toISOString() !== value\n )\n throw new Error(\"Invalid report timestamp\");\n return value;\n};\nconst day = (value: unknown) => {\n if (\n typeof value !== \"string\" ||\n !/^\\d{4}-\\d{2}-\\d{2}$/.test(value) ||\n new Date(value + \"T00:00:00.000Z\").toISOString().slice(0, 10) !== value\n )\n throw new Error(\"Expected a UTC calendar date\");\n return value;\n};\nexport const parseUsageRange = (\n input: unknown,\n now = new Date(),\n): UsageRange => {\n const args = only(input, [\"from\", \"to\"]);\n const tomorrow = new Date(\n Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) +\n DAY_MS,\n );\n const to = day(args.to ?? tomorrow.toISOString().slice(0, 10));\n const from = day(\n args.from ??\n new Date(Date.parse(to) - 30 * DAY_MS).toISOString().slice(0, 10),\n );\n const duration = Date.parse(to) - Date.parse(from);\n if (\n duration <= 0 ||\n duration > 90 * DAY_MS ||\n Date.parse(to) > tomorrow.getTime()\n )\n throw new Error(\n \"Choose a range of 1–90 UTC days, ending no later than tomorrow\",\n );\n return { from, to };\n};\n/** Cursor is a position, never authorization. Consumers must filter by account. */\nexport const encodeReceiptCursor = (cursor: ReceiptCursor) => {\n if (!UUID.test(cursor.id)) throw new Error(\"Invalid receipt cursor\");\n return Buffer.from(\n JSON.stringify({ at: iso(cursor.at), id: cursor.id }),\n ).toString(\"base64url\");\n};\nexport const parseReceiptPage = (input: unknown): ReceiptPageRequest => {\n const args = only(input, [\"limit\", \"cursor\"]);\n const limit = args.limit ?? 20;\n if (\n typeof limit !== \"number\" ||\n !Number.isInteger(limit) ||\n limit < 1 ||\n limit > 50\n )\n throw new Error(\"Receipt limit must be 1–50\");\n if (args.cursor === undefined || args.cursor === null)\n return { limit, cursor: null };\n if (\n typeof args.cursor !== \"string\" ||\n args.cursor.length > 256 ||\n !/^[A-Za-z0-9_-]+$/.test(args.cursor)\n )\n throw new Error(\"Invalid receipt cursor\");\n const decoded: unknown = JSON.parse(\n Buffer.from(args.cursor, \"base64url\").toString(\"utf8\"),\n );\n if (\n !record(decoded) ||\n typeof decoded.at !== \"string\" ||\n typeof decoded.id !== \"string\" ||\n !UUID.test(decoded.id)\n )\n throw new Error(\"Invalid receipt cursor\");\n return { limit, cursor: { at: iso(decoded.at), id: decoded.id } };\n};\nexport const projectBillingStatus = (value: BillingStatus): BillingStatus => ({\n portalAccess: value.portalAccess === true,\n subscription: value.subscription\n ? {\n status: String(value.subscription.status).slice(0, 64),\n renewsAt:\n value.subscription.renewsAt === null\n ? null\n : iso(value.subscription.renewsAt),\n cancelAtPeriodEnd: value.subscription.cancelAtPeriodEnd === true,\n }\n : null,\n credits: {\n remaining: integer(value.credits.remaining),\n reserved: integer(value.credits.reserved),\n purchased: integer(value.credits.purchased),\n promotional: integer(value.credits.promotional),\n debt: integer(value.credits.debt),\n },\n automaticRefill: false,\n});\nexport const projectReceiptPage = (\n value: ReceiptPage,\n limit: number,\n): ReceiptPage => {\n if (value.receipts.length > limit)\n throw new Error(\"Receipt page exceeds its limit\");\n if (value.nextCursor !== null) parseReceiptPage({ cursor: value.nextCursor });\n return {\n receipts: value.receipts.map((row) => {\n if (\n !UUID.test(row.id) ||\n !/^[A-Z]{3}$/.test(row.currency) ||\n ![\"credit_purchase\", \"initial\", \"plan_change\", \"renewal\"].includes(\n row.source,\n ) ||\n ![\"paid\", \"partially_refunded\", \"refunded\"].includes(row.status) ||\n row.refundedAmountCents > row.amountCents\n )\n throw new Error(\"Invalid receipt\");\n return {\n id: row.id,\n issuedAt: iso(row.issuedAt),\n currency: row.currency,\n amountCents: integer(row.amountCents),\n refundedAmountCents: integer(row.refundedAmountCents),\n source: row.source,\n status: row.status,\n };\n }),\n nextCursor: value.nextCursor,\n };\n};\nexport const projectUsageReport = (\n value: CustomerUsageReport,\n range: UsageRange,\n): CustomerUsageReport => {\n if (\n value.from !== range.from ||\n value.to !== range.to ||\n value.byDay.length > 90 ||\n value.byFeature.length > 21\n )\n throw new Error(\"Invalid usage report bounds\");\n const byDay = value.byDay.map((row) => ({\n day: day(row.day),\n credits: integer(row.credits),\n events: integer(row.events),\n }));\n const byFeature = value.byFeature.map((row) => ({\n feature: row.feature.slice(0, 128),\n credits: integer(row.credits),\n events: integer(row.events),\n }));\n const credits = integer(value.creditsConsumed),\n events = integer(value.events);\n for (const rows of [byDay, byFeature])\n if (\n rows.reduce((sum, row) => sum + row.credits, 0) !== credits ||\n rows.reduce((sum, row) => sum + row.events, 0) !== events\n )\n throw new Error(\"Usage breakdown does not reconcile\");\n if (\n new Set(byDay.map((row) => row.day)).size !== byDay.length ||\n byDay.some((row) => row.day < range.from || row.day >= range.to)\n )\n throw new Error(\"Invalid usage days\");\n return {\n from: range.from,\n to: range.to,\n creditsConsumed: credits,\n events,\n byDay,\n byFeature,\n };\n};\n"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "1CCF8D983CF6711964756E2164756E21",
9
+ "names": []
10
+ }
@@ -0,0 +1,9 @@
1
+ # Purchase-only checkout handoffs
2
+
3
+ `@absolutejs/billing/checkout-handoff` issues opaque 256-bit codes from a server-priced quote and authenticated account. Apply `checkoutHandoffPostgresSchemaSql()` explicitly; use `createPostgresCheckoutHandoffs` with the same parameterized SQL adapter as the prepaid ledger. This API is not login, consent to charge, or saved-payment-method access.
4
+
5
+ Deliver `checkoutHandoffUrl(httpsLandingUrl, code)` only on permitted host channels. The fragment prevents ordinary HTTP previews/referrers from receiving the code, but it is still a limited bearer capability visible in the assistant transcript. Never log it. Remove it synchronously before analytics or third-party scripts run. GET renders a neutral page; an explicit same-origin POST atomically exchanges the code. Reject a different signed-in browser account. Set the returned session in a Secure, HttpOnly, host-only SameSite=Strict cookie; retain CSRF only in the browser. Require `checkoutSameOrigin` AND `authorize(session, csrf, browserAccountId)` on every review/payment POST. Codes and sessions expire after 15 minutes; concurrent redemption succeeds once.
6
+
7
+ The limited session reveals only its fixed quote and purchase status. It cannot read profile, balance history, receipts, saved cards, or subscribe. Payment requires a new provider-hosted token and explicit user confirmation of the exact quote. Bind the gateway operation ID to the handoff ID; reuse the existing durable reserve/charge/recover/grant service. Never retry uncertain charges with new IDs. A status read is not a gateway operation. Account deletion/revocation remains the consumer's responsibility on issue and use. Retain financial records; expired unused handoffs may be removed by an operator's retention policy.
8
+
9
+ Host approval and supported UI are independent. MCP form elicitation must not collect payment credentials; use an approved external browser interaction. The handoff alone does not approve a commerce channel. Sources: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation and the MCP package `docs/commerce-host-rules.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/billing",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Provider-neutral pricing and invoice computation used by the hosted AbsoluteJS.ai platform. Converts metered usage into exact integer-micro line items with tiers and allowances.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,6 +47,16 @@
47
47
  "types": "./dist/creditWork.d.ts",
48
48
  "import": "./dist/creditWork.js",
49
49
  "default": "./dist/creditWork.js"
50
+ },
51
+ "./checkout-handoff": {
52
+ "types": "./dist/checkoutHandoff.d.ts",
53
+ "import": "./dist/checkoutHandoff.js",
54
+ "default": "./dist/checkoutHandoff.js"
55
+ },
56
+ "./reports": {
57
+ "types": "./dist/reports.d.ts",
58
+ "import": "./dist/reports.js",
59
+ "default": "./dist/reports.js"
50
60
  }
51
61
  },
52
62
  "publishConfig": {
@@ -57,10 +67,11 @@
57
67
  "README.md",
58
68
  "changelog.json",
59
69
  "dist",
60
- "docs/prepaid-credits.md"
70
+ "docs/prepaid-credits.md",
71
+ "docs/checkout-handoffs.md"
61
72
  ],
62
73
  "scripts": {
63
- "build": "rm -rf dist && bun build src/index.ts src/ledger.ts src/prepaid.ts src/prepaidPostgres.ts src/creditWork.ts src/manifest.ts --root ./src --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json && absolute-manifest emit",
74
+ "build": "rm -rf dist && bun build src/index.ts src/ledger.ts src/prepaid.ts src/prepaidPostgres.ts src/creditWork.ts src/checkoutHandoff.ts src/reports.ts src/manifest.ts --root ./src --outdir dist --sourcemap --target=bun && tsc --project tsconfig.build.json && absolute-manifest emit",
64
75
  "test": "bun test tests/",
65
76
  "typecheck": "tsc --noEmit",
66
77
  "format": "prettier --write \"./**/*.{ts,json,md}\"",