@suluk/cost 0.1.0 → 0.1.2
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/README.md +35 -0
- package/package.json +19 -8
- package/src/contract.ts +76 -28
- package/src/event.ts +112 -0
- package/src/index.ts +9 -0
- package/src/types.ts +62 -0
- package/test/event.test.ts +59 -0
- package/test/jobs.test.ts +39 -0
- package/test/reconciliation.test.ts +42 -0
- package/test/trigger.test.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/cost</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center"><b>Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is.</b></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/cost
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The Suluk cycle
|
|
28
|
+
|
|
29
|
+
`@suluk/cost` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
|
|
30
|
+
preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
|
|
31
|
+
[main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/cost",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Cost as a contract facet: declare per-operation cost (incl. third-party usage), bubble it into the v4 doc/Scalar/tests, and meter the ACTUAL per-user cost at runtime (frontend action -> operation -> third-party). Display as-is. CANDIDATE tooling.",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/cost"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/core": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.7"
|
|
12
23
|
},
|
|
13
24
|
"peerDependencies": {
|
|
14
25
|
"hono": "^4.0.0"
|
|
@@ -21,8 +32,8 @@
|
|
|
21
32
|
"devDependencies": {
|
|
22
33
|
"@types/bun": "latest",
|
|
23
34
|
"hono": "^4.0.0",
|
|
24
|
-
"@suluk/hono": "0.1.
|
|
25
|
-
"@suluk/openapi-compat": "0.1.
|
|
35
|
+
"@suluk/hono": "^0.1.2",
|
|
36
|
+
"@suluk/openapi-compat": "^0.1.2",
|
|
26
37
|
"zod": "^4.4.3"
|
|
27
38
|
},
|
|
28
39
|
"scripts": {
|
package/src/contract.ts
CHANGED
|
@@ -4,19 +4,45 @@
|
|
|
4
4
|
* `x-*`), Scalar/Swagger render it, and a coverage audit can flag operations that never declared a cost.
|
|
5
5
|
* The contract tells you what an operation *should* cost; the runtime meter tells you what it *did*.
|
|
6
6
|
*/
|
|
7
|
-
import type { OpenAPIv4Document, PathItem, Request } from "@suluk/core";
|
|
8
|
-
import type
|
|
7
|
+
import type { OpenAPIv4Document, PathItem, Request, SulukJob } from "@suluk/core";
|
|
8
|
+
import { UNATTRIBUTED, type CostModel, type CostTrigger, type UsageReport } from "./types";
|
|
9
9
|
|
|
10
10
|
export const COST_EXT = "x-suluk-cost";
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Every background JOB (C025) on the document, as {path, name, job} — non-HTTP cron/queue work. */
|
|
13
|
+
export function eachJob(doc: OpenAPIv4Document): { path: string; name: string; job: SulukJob }[] {
|
|
14
|
+
const jobs = (doc as { ["x-suluk-jobs"]?: Record<string, SulukJob> })["x-suluk-jobs"] ?? {};
|
|
15
|
+
return Object.entries(jobs).map(([name, job]) => ({ path: `jobs/${name}`, name, job }));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every cost LOCUS in the document — path requests, C018 webhooks, AND C025 jobs — with its declared model. */
|
|
19
|
+
function costLoci(doc: OpenAPIv4Document): { path: string; name: string; model: CostModel | undefined }[] {
|
|
20
|
+
const out = eachOperation(doc).map(({ path, name, req }) => ({ path, name, model: costOf(req) }));
|
|
21
|
+
for (const { path, name, job } of eachJob(doc)) out.push({ path, name, model: (job as unknown as Record<string, unknown>)[COST_EXT] as CostModel | undefined });
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every named operation in the document — path requests AND C018 webhooks (which are Requests carrying facets) —
|
|
27
|
+
* as {path, name, req}. Background-event cost lives on a webhook op, so every cost reader walks this, not just paths.
|
|
28
|
+
*/
|
|
29
|
+
export function eachOperation(doc: OpenAPIv4Document): { path: string; name: string; req: Request }[] {
|
|
30
|
+
const out: { path: string; name: string; req: Request }[] = [];
|
|
31
|
+
for (const [path, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
32
|
+
for (const [name, req] of Object.entries((piRaw as PathItem).requests ?? {})) out.push({ path, name, req: req as Request });
|
|
33
|
+
}
|
|
34
|
+
for (const [name, req] of Object.entries((doc as { webhooks?: Record<string, Request> }).webhooks ?? {})) {
|
|
35
|
+
out.push({ path: `webhooks/${name}`, name, req: req as Request });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Annotate a v4 document in place-safe (returns a new doc): set x-suluk-cost on each named operation (incl. webhooks). */
|
|
13
41
|
export function annotateCosts(doc: OpenAPIv4Document, costs: Record<string, CostModel>): OpenAPIv4Document {
|
|
14
42
|
const out: OpenAPIv4Document = structuredClone(doc);
|
|
15
|
-
for (const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (model) (req as Request & Record<string, unknown>)[COST_EXT] = model;
|
|
19
|
-
}
|
|
43
|
+
for (const { name, req } of eachOperation(out)) {
|
|
44
|
+
const model = costs[name];
|
|
45
|
+
if (model) (req as Request & Record<string, unknown>)[COST_EXT] = model;
|
|
20
46
|
}
|
|
21
47
|
return out;
|
|
22
48
|
}
|
|
@@ -26,8 +52,18 @@ export function costOf(req: Request): CostModel | undefined {
|
|
|
26
52
|
return (req as Request & Record<string, unknown>)[COST_EXT] as CostModel | undefined;
|
|
27
53
|
}
|
|
28
54
|
|
|
55
|
+
/** The trigger an operation's cost declares (C024; default "synchronous"). */
|
|
56
|
+
export function triggerOf(model: CostModel | undefined): CostTrigger {
|
|
57
|
+
return model?.trigger ?? "synchronous";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Does this cost accrue on a BACKGROUND event (a non-synchronous trigger) rather than the declaring op's own run? */
|
|
61
|
+
export function isDeferredCost(model: CostModel | undefined): boolean {
|
|
62
|
+
return !!model && triggerOf(model) !== "synchronous";
|
|
63
|
+
}
|
|
64
|
+
|
|
29
65
|
export interface CostFinding {
|
|
30
|
-
code: "no-cost-model" | "zero-cost";
|
|
66
|
+
code: "no-cost-model" | "zero-cost" | "unattributed-background-cost" | "unverified-attribution" | "reconciliation-incomplete";
|
|
31
67
|
severity: "warn" | "info";
|
|
32
68
|
path: string;
|
|
33
69
|
operation: string;
|
|
@@ -35,34 +71,46 @@ export interface CostFinding {
|
|
|
35
71
|
}
|
|
36
72
|
|
|
37
73
|
/**
|
|
38
|
-
* Cost-coverage audit: which operations have NOT declared what they cost
|
|
39
|
-
*
|
|
74
|
+
* Cost-coverage audit: which operations have NOT declared what they cost — plus (C024) the background-cost
|
|
75
|
+
* disciplines: a deferred cost that resolves no principal would bill to @unattributed (fail LOUD, never silent),
|
|
76
|
+
* and an attribution read off an UNVERIFIED event payload is attacker-controllable. Walks paths AND webhooks.
|
|
40
77
|
*/
|
|
41
78
|
export function costAudit(doc: OpenAPIv4Document): CostFinding[] {
|
|
42
79
|
const findings: CostFinding[] = [];
|
|
43
|
-
for (const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
80
|
+
for (const { path, name, model } of costLoci(doc)) {
|
|
81
|
+
if (!model) {
|
|
82
|
+
findings.push({ code: "no-cost-model", severity: "warn", path, operation: name, message: "operation declares no cost — its cost to you is unknown (not assumed zero)" });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!model.components.length) {
|
|
86
|
+
findings.push({ code: "zero-cost", severity: "info", path, operation: name, message: "operation declares an empty cost model (explicitly free)" });
|
|
87
|
+
}
|
|
88
|
+
if (isDeferredCost(model)) {
|
|
89
|
+
const attr = model.attribution;
|
|
90
|
+
const unattributed = !attr || (attr.strategy === "event-expression" && !attr.expression);
|
|
91
|
+
if (unattributed) {
|
|
92
|
+
findings.push({ code: "unattributed-background-cost", severity: "warn", path, operation: name, message: `${triggerOf(model)} cost declares no resolvable principal — it would bill to ${UNATTRIBUTED}` });
|
|
93
|
+
} else if (attr.strategy === "event-expression" && attr.trust !== "verified") {
|
|
94
|
+
findings.push({ code: "unverified-attribution", severity: "warn", path, operation: name, message: "attribution reads an UNVERIFIED event payload (attacker-controllable) — require a verified signature" });
|
|
50
95
|
}
|
|
51
96
|
}
|
|
97
|
+
// C026: a cost that claims to reconcile against the actual charge but can't read it falls back to the estimate.
|
|
98
|
+
if (model.reconciliationBasis === "payload-reconciled" && !model.amountExpression) {
|
|
99
|
+
findings.push({ code: "reconciliation-incomplete", severity: "warn", path, operation: name, message: "declares payload-reconciled but no amountExpression — the actual charge can't be read; it falls back to the estimate" });
|
|
100
|
+
}
|
|
52
101
|
}
|
|
53
102
|
return findings;
|
|
54
103
|
}
|
|
55
104
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
105
|
+
export interface CostRow { operation: string; path: string; estimateMicroUsd: number; sources: string[]; trigger: CostTrigger }
|
|
106
|
+
|
|
107
|
+
/** The declared costs across the document (paths + webhooks + jobs), for display (the cockpit/admin show this raw). */
|
|
108
|
+
export function costTable(doc: OpenAPIv4Document): CostRow[] {
|
|
109
|
+
const rows: CostRow[] = [];
|
|
110
|
+
for (const { path, name, model } of costLoci(doc)) {
|
|
111
|
+
if (!model) continue;
|
|
112
|
+
const fixed = model.components.filter((c) => c.basis === "per-call").reduce((s, c) => s + c.microUsd, 0);
|
|
113
|
+
rows.push({ operation: name, path, estimateMicroUsd: model.estimateMicroUsd ?? fixed, sources: [...new Set(model.components.map((c) => c.source))], trigger: triggerOf(model) });
|
|
66
114
|
}
|
|
67
115
|
return rows;
|
|
68
116
|
}
|
package/src/event.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-event cost (C024 runtime counterpart). The request meter (meter.ts) is Hono-Context-bound —
|
|
3
|
+
* `principalOf(c: Context)` reads a live caller. A FIRED event (a Stripe webhook, a cron tick, a queue message)
|
|
4
|
+
* has NO such Context, so this is the separate Context-free path the council flagged: given the fired event payload
|
|
5
|
+
* + the operation's declared cost model (with its `trigger` / `attribution` / `idempotencyKey`), it resolves WHO
|
|
6
|
+
* pays (the runtime attribution expression — never the static matcher) and a DEDUPE key, and builds the CostEvent.
|
|
7
|
+
*
|
|
8
|
+
* Pure + reproducible: `at` is passed in, the event payload is the only input, nothing reads ambient state.
|
|
9
|
+
*/
|
|
10
|
+
import { computeCost } from "./contract";
|
|
11
|
+
import { UNATTRIBUTED, type CostModel, type CostEvent, type UsageReport } from "./types";
|
|
12
|
+
import type { CostSink } from "./meter";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a C018 runtime-expression against a fired event. Supports `{$event.id}`, `{$event.<key>}`, and a
|
|
16
|
+
* JSON-Pointer tail `{$event.body#/customer}` / `{$event.body#/data/object/customer}`. Returns the stringified
|
|
17
|
+
* value, or undefined when it doesn't resolve. Pure; never throws (an unresolvable expression is undefined, not an error).
|
|
18
|
+
*/
|
|
19
|
+
export function resolveEventExpression(expression: string, event: Record<string, unknown>): string | undefined {
|
|
20
|
+
const m = /^\{\$event\.([^}]+)\}$/.exec(expression.trim());
|
|
21
|
+
if (!m) return undefined;
|
|
22
|
+
const [base, pointer] = m[1].split("#");
|
|
23
|
+
let node: unknown = base ? (event as Record<string, unknown>)[base] : event;
|
|
24
|
+
if (pointer) {
|
|
25
|
+
for (const raw of pointer.split("/").filter(Boolean)) {
|
|
26
|
+
const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~"); // JSON-Pointer unescape
|
|
27
|
+
if (node && typeof node === "object") node = (node as Record<string, unknown>)[seg];
|
|
28
|
+
else return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return node == null ? undefined : String(node);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the principal charged for a fired event per the model's attribution strategy. Returns the `@unattributed`
|
|
36
|
+
* sentinel (never silent) when nothing resolves: `session`/`job-stamped` use the supplied principal; `event-expression`
|
|
37
|
+
* reads it from the payload. NOTE: an `event-expression` with `trust !== "verified"` is attacker-controllable — the
|
|
38
|
+
* caller MUST gate it behind a verified webhook signature before trusting the result for billing.
|
|
39
|
+
*/
|
|
40
|
+
export function attributePrincipal(model: CostModel, event: Record<string, unknown>, suppliedPrincipal?: string): string {
|
|
41
|
+
const attr = model.attribution;
|
|
42
|
+
if (!attr || attr.strategy === "session" || attr.strategy === "job-stamped") return suppliedPrincipal ?? UNATTRIBUTED;
|
|
43
|
+
if (attr.strategy === "event-expression" && attr.expression) return resolveEventExpression(attr.expression, event) ?? UNATTRIBUTED;
|
|
44
|
+
return UNATTRIBUTED;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EventCostInput {
|
|
48
|
+
/** the operation name whose cost fired (the webhook/op by-name handle). */
|
|
49
|
+
operation: string;
|
|
50
|
+
/** its declared cost model (carrying trigger / attribution / idempotencyKey). */
|
|
51
|
+
model: CostModel;
|
|
52
|
+
/** the fired event payload. */
|
|
53
|
+
event: Record<string, unknown>;
|
|
54
|
+
/** wall-clock ms (passed in — reproducible). */
|
|
55
|
+
at: number;
|
|
56
|
+
/** any metered third-party usage the handler measured. */
|
|
57
|
+
usage?: UsageReport[];
|
|
58
|
+
/** for `session`/`job-stamped` attribution: the principal the job/session carries. */
|
|
59
|
+
suppliedPrincipal?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the ACTUAL charged amount (in µ$) from the event when the model is `payload-reconciled` (C026), else
|
|
64
|
+
* undefined. Reads the runtime amount-expression (e.g. the Stripe event amount) and converts from its declared unit
|
|
65
|
+
* — so the recorded cost is the third party's real invoice line, not the operator's declared estimate.
|
|
66
|
+
*/
|
|
67
|
+
export function reconciledAmount(model: CostModel, event: Record<string, unknown>): number | undefined {
|
|
68
|
+
if (model.reconciliationBasis !== "payload-reconciled" || !model.amountExpression) return undefined;
|
|
69
|
+
const raw = resolveEventExpression(model.amountExpression, event);
|
|
70
|
+
const n = raw == null ? NaN : Number(raw);
|
|
71
|
+
if (!Number.isFinite(n)) return undefined;
|
|
72
|
+
const unit = model.amountUnit ?? "micro-usd";
|
|
73
|
+
return Math.round(unit === "cents" ? n * 10_000 : unit === "usd" ? n * 1_000_000 : n);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Build the CostEvent for a FIRED background event — pure. Stamps the trigger, resolves principal + dedupeKey,
|
|
77
|
+
* and (C026) uses the payload-reconciled amount as the authoritative total when the model declares one. */
|
|
78
|
+
export function eventCostEvent(input: EventCostInput): CostEvent {
|
|
79
|
+
let { breakdown, totalMicroUsd } = computeCost(input.model, input.usage ?? []);
|
|
80
|
+
const reconciled = reconciledAmount(input.model, input.event);
|
|
81
|
+
if (reconciled !== undefined) {
|
|
82
|
+
const source = input.model.components[0]?.source ?? "reconciled";
|
|
83
|
+
breakdown = [{ source, microUsd: reconciled }]; // the actual charge replaces the estimate
|
|
84
|
+
totalMicroUsd = reconciled;
|
|
85
|
+
}
|
|
86
|
+
const dedupeKey = input.model.idempotencyKey ? resolveEventExpression(input.model.idempotencyKey, input.event) : undefined;
|
|
87
|
+
return {
|
|
88
|
+
at: input.at,
|
|
89
|
+
operation: input.operation,
|
|
90
|
+
principal: attributePrincipal(input.model, input.event, input.suppliedPrincipal),
|
|
91
|
+
trigger: input.model.trigger ?? "synchronous",
|
|
92
|
+
...(dedupeKey ? { dedupeKey } : {}),
|
|
93
|
+
...(reconciled !== undefined ? { reconciled: true } : {}),
|
|
94
|
+
breakdown,
|
|
95
|
+
totalMicroUsd,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Record a fired event's cost into a sink, deduped by its `dedupeKey` against `seen` (so at-least-once delivery
|
|
101
|
+
* can't double-charge). Returns the recorded event, or null when it was a duplicate. `seen` is the app's dedup
|
|
102
|
+
* store (an in-memory Set for dev; a durable KV/DO for prod).
|
|
103
|
+
*/
|
|
104
|
+
export async function recordEventCost(sink: CostSink, input: EventCostInput, seen?: Set<string>): Promise<CostEvent | null> {
|
|
105
|
+
const event = eventCostEvent(input);
|
|
106
|
+
if (event.dedupeKey && seen) {
|
|
107
|
+
if (seen.has(event.dedupeKey)) return null; // already recorded — at-least-once delivery
|
|
108
|
+
seen.add(event.dedupeKey);
|
|
109
|
+
}
|
|
110
|
+
await sink.record(event);
|
|
111
|
+
return event;
|
|
112
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export {
|
|
9
9
|
type CostBasis, type CostComponent, type CostModel, type UsageReport, type CostEvent, formatMicroUsd,
|
|
10
|
+
// C024 — background-event cost: WHEN it fires (trigger) + WHO pays (attribution), orthogonal to basis (HOW it meters).
|
|
11
|
+
type CostTrigger, type CostAttribution, UNATTRIBUTED,
|
|
12
|
+
// C026 — reconciliation: declared-estimate vs the third party's actual (payload-reconciled) charge.
|
|
13
|
+
type ReconciliationBasis,
|
|
10
14
|
} from "./types";
|
|
11
15
|
export {
|
|
12
16
|
COST_EXT, annotateCosts, costOf, costAudit, costTable, computeCost, type CostFinding,
|
|
17
|
+
eachOperation, eachJob, triggerOf, isDeferredCost, type CostRow,
|
|
13
18
|
} from "./contract";
|
|
14
19
|
export {
|
|
15
20
|
costMeter, recordUsage, MemoryCostSink, type CostSink, type CostMeterOptions,
|
|
16
21
|
} from "./meter";
|
|
22
|
+
// C024 — the Context-free background-event cost path (a fired webhook/cron/queue event, no live caller).
|
|
23
|
+
export {
|
|
24
|
+
resolveEventExpression, attributePrincipal, eventCostEvent, recordEventCost, reconciledAmount, type EventCostInput,
|
|
25
|
+
} from "./event";
|
|
17
26
|
export { summarize, principalCost, type CostSummary } from "./ledger";
|
package/src/types.ts
CHANGED
|
@@ -25,12 +25,68 @@ export interface CostComponent {
|
|
|
25
25
|
description?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* WHEN/WHAT fires a cost (C024) — a STATIC, locally-decidable enum (the same KIND as {@link CostBasis}). Default
|
|
30
|
+
* "synchronous" ⇒ every existing declaration is unchanged (zero migration). Strictly DESCRIPTIVE: it names where the
|
|
31
|
+
* cost accrues, asserting NO event-channel / delivery-protocol semantics — the fence that keeps it orthogonal to
|
|
32
|
+
* C018's deliberately-deferred async scope. Three axes stay orthogonal: `basis` = HOW it meters, `trigger` = WHEN it
|
|
33
|
+
* fires, `attribution` = WHO pays.
|
|
34
|
+
*/
|
|
35
|
+
export type CostTrigger =
|
|
36
|
+
| "synchronous" // accrues when this operation's own route runs (default; backwards-compatible)
|
|
37
|
+
| "webhook-received" // accrues when an incoming webhook (C018) fires
|
|
38
|
+
| "scheduled" // accrues when a scheduled / cron job runs
|
|
39
|
+
| "queue-consumed" // accrues when a queue consumer processes a message
|
|
40
|
+
| "callback-completed"; // accrues when an out-of-band callback (C018) completes
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WHO is charged when a third party fires the event with no live session (C024) — a declared STRATEGY the runtime
|
|
44
|
+
* resolves a concrete principal from, modeled on `SulukRateLimit.key`. The `expression` is RUNTIME-ONLY: a C018
|
|
45
|
+
* runtime-expression that NEVER enters the static matcher (D1-consistent, exactly as C018 walls its callback keys).
|
|
46
|
+
*/
|
|
47
|
+
export interface CostAttribution {
|
|
48
|
+
/** session = the live caller (the existing path); event-expression = read the principal from the event payload at
|
|
49
|
+
* runtime; job-stamped = the job carries its own principal. */
|
|
50
|
+
strategy: "session" | "event-expression" | "job-stamped";
|
|
51
|
+
/** for event-expression: a C018 runtime-expression (e.g. "{$event.body#/customer}"). Runtime-resolved only. */
|
|
52
|
+
expression?: string;
|
|
53
|
+
/** is the attribution input authentic? An event-expression off an UNVERIFIED webhook payload is attacker-controlled
|
|
54
|
+
* — honor it as authoritative only when "verified" (a signature/secret check the runtime performs). */
|
|
55
|
+
trust?: "verified" | "unverified-payload";
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
export interface CostModel {
|
|
29
59
|
components: CostComponent[];
|
|
30
60
|
/** Optional typical total for one call (µ$), for display + tests when usage isn't yet known. */
|
|
31
61
|
estimateMicroUsd?: number;
|
|
62
|
+
/** WHEN/WHAT fires this cost (C024; default "synchronous"). STATIC — decouples accrual-time from the declaring op. */
|
|
63
|
+
trigger?: CostTrigger;
|
|
64
|
+
/** the by-name handle (C009) of the webhook/callback/op whose firing accrues this cost (for a non-sync trigger). */
|
|
65
|
+
triggerRef?: string;
|
|
66
|
+
/** WHO is charged when there is no live session (runtime strategy; the expression never enters the static matcher). */
|
|
67
|
+
attribution?: CostAttribution;
|
|
68
|
+
/** a runtime-expression yielding a stable id to DEDUPE at-least-once delivery (e.g. "{$event.id}") — prevents
|
|
69
|
+
* double-counting a cost charged on both the receipt op and the triggered op. Runtime-only. */
|
|
70
|
+
idempotencyKey?: string;
|
|
71
|
+
/**
|
|
72
|
+
* How the amount RECONCILES with the third party's actual charge (C026; default "declared-estimate"). A declared
|
|
73
|
+
* estimate is a guess; "payload-reconciled" reads the ACTUAL charged amount from the event at runtime (the real
|
|
74
|
+
* invoice line — proration/tax/refund included), so the recorded cost is authoritative, not an approximation.
|
|
75
|
+
*/
|
|
76
|
+
reconciliationBasis?: ReconciliationBasis;
|
|
77
|
+
/** for "payload-reconciled": a runtime-expression yielding the ACTUAL amount (e.g. "{$event.body#/amount}").
|
|
78
|
+
* Runtime-only — never the static matcher. Interpreted in `amountUnit`. */
|
|
79
|
+
amountExpression?: string;
|
|
80
|
+
/** the unit `amountExpression` yields (default "micro-usd"). "cents" (Stripe) → ×10_000; "usd" → ×1_000_000. */
|
|
81
|
+
amountUnit?: "micro-usd" | "cents" | "usd";
|
|
32
82
|
}
|
|
33
83
|
|
|
84
|
+
/** Whether a cost's amount is a declared guess or read from the event payload at runtime (C026). */
|
|
85
|
+
export type ReconciliationBasis = "declared-estimate" | "payload-reconciled";
|
|
86
|
+
|
|
87
|
+
/** The principal sentinel for a background cost that resolved to NO principal — billed to nobody, but never silent. */
|
|
88
|
+
export const UNATTRIBUTED = "@unattributed" as const;
|
|
89
|
+
|
|
34
90
|
/** A measured usage report for one variable component during a request (e.g. {source:"openai", units: 1350}). */
|
|
35
91
|
export interface UsageReport {
|
|
36
92
|
source: string;
|
|
@@ -47,6 +103,12 @@ export interface CostEvent {
|
|
|
47
103
|
operation: string;
|
|
48
104
|
/** The frontend action that triggered it (a button-click id), if the client tagged the request. */
|
|
49
105
|
action?: string;
|
|
106
|
+
/** How this cost fired (C024; default "synchronous"). A non-sync value marks a background charge. */
|
|
107
|
+
trigger?: CostTrigger;
|
|
108
|
+
/** Dedupe id for at-least-once event delivery — two events with the same key are the SAME charge (C024). */
|
|
109
|
+
dedupeKey?: string;
|
|
110
|
+
/** true ⇒ totalMicroUsd is the third party's ACTUAL charge read from the event (C026), not a declared estimate. */
|
|
111
|
+
reconciled?: boolean;
|
|
50
112
|
/** Per-source breakdown (µ$). */
|
|
51
113
|
breakdown: { source: string; microUsd: number }[];
|
|
52
114
|
/** Total µ$ for the request. */
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
resolveEventExpression, attributePrincipal, eventCostEvent, recordEventCost,
|
|
4
|
+
MemoryCostSink, UNATTRIBUTED, type CostModel,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
|
|
7
|
+
// the motivating case: Stripe fires payment_intent.succeeded → it charges you, attributed to the customer.
|
|
8
|
+
const stripeEvent = { id: "evt_123", type: "payment_intent.succeeded", body: { customer: "cus_42", amount: 2900 } };
|
|
9
|
+
const chargeModel: CostModel = {
|
|
10
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
|
|
11
|
+
trigger: "webhook-received",
|
|
12
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
13
|
+
idempotencyKey: "{$event.id}",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("C024 runtime — resolve a C018 runtime-expression against a fired event", () => {
|
|
17
|
+
test("body JSON-pointer, top-level key, and an unresolvable expression", () => {
|
|
18
|
+
expect(resolveEventExpression("{$event.body#/customer}", stripeEvent)).toBe("cus_42");
|
|
19
|
+
expect(resolveEventExpression("{$event.id}", stripeEvent)).toBe("evt_123");
|
|
20
|
+
expect(resolveEventExpression("{$event.body#/missing}", stripeEvent)).toBeUndefined();
|
|
21
|
+
expect(resolveEventExpression("not-an-expression", stripeEvent)).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("C024 runtime — attribute the principal (fail to @unattributed, never silent)", () => {
|
|
26
|
+
test("event-expression reads the principal from the payload", () => {
|
|
27
|
+
expect(attributePrincipal(chargeModel, stripeEvent)).toBe("cus_42");
|
|
28
|
+
});
|
|
29
|
+
test("session/job-stamped use the supplied principal; missing → @unattributed", () => {
|
|
30
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "session" } }, stripeEvent, "u1")).toBe("u1");
|
|
31
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "session" } }, stripeEvent)).toBe(UNATTRIBUTED);
|
|
32
|
+
expect(attributePrincipal({ components: [], attribution: { strategy: "event-expression" } }, stripeEvent)).toBe(UNATTRIBUTED); // no expression
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("C024 runtime — build + record the background CostEvent", () => {
|
|
37
|
+
test("eventCostEvent stamps the trigger, principal, dedupeKey, and the computed total", () => {
|
|
38
|
+
const e = eventCostEvent({ operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1000 });
|
|
39
|
+
expect(e).toMatchObject({ operation: "stripeCharge", principal: "cus_42", trigger: "webhook-received", dedupeKey: "evt_123", totalMicroUsd: 2900 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("recordEventCost dedupes at-least-once delivery (the same event id records ONCE)", async () => {
|
|
43
|
+
const sink = new MemoryCostSink();
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
const a = await recordEventCost(sink, { operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1000 }, seen);
|
|
46
|
+
const b = await recordEventCost(sink, { operation: "stripeCharge", model: chargeModel, event: stripeEvent, at: 1001 }, seen); // redelivery
|
|
47
|
+
expect(a).not.toBeNull();
|
|
48
|
+
expect(b).toBeNull(); // deduped — not double-charged
|
|
49
|
+
expect(sink.events()).toHaveLength(1);
|
|
50
|
+
expect(sink.events()[0].principal).toBe("cus_42");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a cost that can't attribute records to @unattributed (visible, not lost)", async () => {
|
|
54
|
+
const sink = new MemoryCostSink();
|
|
55
|
+
const orphan: CostModel = { components: [{ source: "stripe", basis: "per-call", microUsd: 500 }], trigger: "webhook-received" };
|
|
56
|
+
await recordEventCost(sink, { operation: "x", model: orphan, event: stripeEvent, at: 1 });
|
|
57
|
+
expect(sink.events()[0].principal).toBe(UNATTRIBUTED);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { eachJob, costAudit, costTable } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
// C025: cron + queue jobs have NO inbound Request, so they live in the x-suluk-jobs vendor map (not paths/webhooks).
|
|
6
|
+
const doc = {
|
|
7
|
+
openapi: "4.0.0-candidate",
|
|
8
|
+
info: { title: "t", version: "1" },
|
|
9
|
+
paths: {},
|
|
10
|
+
"x-suluk-jobs": {
|
|
11
|
+
nightlyRollup: {
|
|
12
|
+
trigger: "scheduled", schedule: "0 0 * * *",
|
|
13
|
+
"x-suluk-cost": { components: [{ source: "compute", basis: "per-call", microUsd: 500 }], trigger: "scheduled", attribution: { strategy: "job-stamped" } },
|
|
14
|
+
},
|
|
15
|
+
drainEmailQueue: {
|
|
16
|
+
trigger: "queue-consumed", queue: "emails",
|
|
17
|
+
"x-suluk-cost": { components: [{ source: "resend", basis: "per-call", microUsd: 100 }], trigger: "queue-consumed" }, // no attribution
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
} as unknown as OpenAPIv4Document;
|
|
21
|
+
|
|
22
|
+
describe("C025 — x-suluk-jobs: cron/queue cost gets a first-class home", () => {
|
|
23
|
+
test("eachJob enumerates the jobs map", () => {
|
|
24
|
+
expect(eachJob(doc).map((j) => j.name).sort()).toEqual(["drainEmailQueue", "nightlyRollup"]);
|
|
25
|
+
expect(eachJob(doc).find((j) => j.name === "nightlyRollup")?.job.schedule).toBe("0 0 * * *");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("costTable includes job rows with their non-synchronous trigger", () => {
|
|
29
|
+
const rows = costTable(doc);
|
|
30
|
+
expect(rows.find((r) => r.operation === "nightlyRollup")).toMatchObject({ path: "jobs/nightlyRollup", trigger: "scheduled", estimateMicroUsd: 500 });
|
|
31
|
+
expect(rows.find((r) => r.operation === "drainEmailQueue")?.trigger).toBe("queue-consumed");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("costAudit applies the same fail-loud discipline to jobs: a job-stamped job is clean, an unattributed one is flagged", () => {
|
|
35
|
+
const findings = costAudit(doc);
|
|
36
|
+
expect(findings.find((f) => f.operation === "nightlyRollup" && f.code === "unattributed-background-cost")).toBeUndefined(); // job-stamped declares its principal
|
|
37
|
+
expect(findings.find((f) => f.operation === "drainEmailQueue")?.code).toBe("unattributed-background-cost"); // no attribution → fail loud
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { eventCostEvent, reconciledAmount, costAudit, annotateCosts, type CostModel } from "../src/index";
|
|
3
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
4
|
+
|
|
5
|
+
// Stripe fires payment_intent.succeeded with the REAL amount (cents) — reconcile against it, not the estimate.
|
|
6
|
+
const stripeEvent = { id: "evt_1", body: { customer: "cus_9", amount: 3175 } }; // $31.75 actual (incl. tax/proration)
|
|
7
|
+
const reconciledModel: CostModel = {
|
|
8
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 29_000_000 }], // declared estimate: $29
|
|
9
|
+
estimateMicroUsd: 29_000_000,
|
|
10
|
+
trigger: "webhook-received",
|
|
11
|
+
reconciliationBasis: "payload-reconciled",
|
|
12
|
+
amountExpression: "{$event.body#/amount}",
|
|
13
|
+
amountUnit: "cents",
|
|
14
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("C026 — reconciliation: record the third party's ACTUAL charge, not the estimate", () => {
|
|
18
|
+
test("reconciledAmount reads the payload amount and converts cents → µ$", () => {
|
|
19
|
+
expect(reconciledAmount(reconciledModel, stripeEvent)).toBe(31_750_000); // 3175 cents × 10_000
|
|
20
|
+
expect(reconciledAmount({ ...reconciledModel, amountUnit: "usd" }, { body: { amount: 31.75 } } as never)).toBe(31_750_000);
|
|
21
|
+
expect(reconciledAmount({ components: [] }, stripeEvent)).toBeUndefined(); // not payload-reconciled
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("eventCostEvent uses the reconciled amount as the authoritative total + flags reconciled", () => {
|
|
25
|
+
const e = eventCostEvent({ operation: "stripeCharge", model: reconciledModel, event: stripeEvent, at: 1 });
|
|
26
|
+
expect(e.totalMicroUsd).toBe(31_750_000); // the ACTUAL $31.75, not the declared $29
|
|
27
|
+
expect(e.reconciled).toBe(true);
|
|
28
|
+
expect(e.breakdown).toEqual([{ source: "stripe", microUsd: 31_750_000 }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a declared-estimate cost is unchanged (the existing path; no reconciliation)", () => {
|
|
32
|
+
const e = eventCostEvent({ operation: "x", model: { components: [{ source: "compute", basis: "per-call", microUsd: 500 }], trigger: "scheduled" }, event: stripeEvent, at: 1 });
|
|
33
|
+
expect(e.totalMicroUsd).toBe(500);
|
|
34
|
+
expect(e.reconciled).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("costAudit flags a payload-reconciled cost that can't read the amount (incomplete)", () => {
|
|
38
|
+
const doc = { openapi: "4.0.0-candidate", info: { title: "t", version: "1" }, paths: { p: { requests: { op: { method: "post", responses: {} } } } } } as unknown as OpenAPIv4Document;
|
|
39
|
+
const annotated = annotateCosts(doc, { op: { components: [{ source: "stripe", basis: "per-call", microUsd: 1 }], reconciliationBasis: "payload-reconciled" } }); // no amountExpression
|
|
40
|
+
expect(costAudit(annotated).find((f) => f.operation === "op" && f.code === "reconciliation-incomplete")).toBeDefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
annotateCosts, costAudit, costTable, eachOperation, triggerOf, isDeferredCost, costOf, UNATTRIBUTED,
|
|
4
|
+
type CostModel,
|
|
5
|
+
} from "../src/index";
|
|
6
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
7
|
+
|
|
8
|
+
// a doc with a path op + a C018 webhook op
|
|
9
|
+
const doc = {
|
|
10
|
+
openapi: "4.0.0-candidate",
|
|
11
|
+
info: { title: "t", version: "1" },
|
|
12
|
+
paths: { order: { requests: { createOrder: { method: "post", responses: {} } } } },
|
|
13
|
+
webhooks: { stripeCharge: { method: "post", responses: {} } },
|
|
14
|
+
} as unknown as OpenAPIv4Document;
|
|
15
|
+
|
|
16
|
+
describe("C024 — background-event cost: the three orthogonal axes", () => {
|
|
17
|
+
test("triggerOf defaults to synchronous; isDeferredCost flags a non-sync trigger", () => {
|
|
18
|
+
expect(triggerOf(undefined)).toBe("synchronous");
|
|
19
|
+
expect(triggerOf({ components: [] })).toBe("synchronous");
|
|
20
|
+
expect(isDeferredCost({ components: [], trigger: "webhook-received" })).toBe(true);
|
|
21
|
+
expect(isDeferredCost({ components: [] })).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("eachOperation walks paths AND webhooks (the background-cost locus)", () => {
|
|
25
|
+
expect(eachOperation(doc).map((o) => o.name).sort()).toEqual(["createOrder", "stripeCharge"]);
|
|
26
|
+
expect(eachOperation(doc).find((o) => o.name === "stripeCharge")?.path).toBe("webhooks/stripeCharge");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("annotateCosts annotates a webhook op; the cost reads back with its trigger", () => {
|
|
30
|
+
const charge: CostModel = {
|
|
31
|
+
components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }],
|
|
32
|
+
trigger: "webhook-received",
|
|
33
|
+
triggerRef: "stripeCharge",
|
|
34
|
+
attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" },
|
|
35
|
+
};
|
|
36
|
+
const annotated = annotateCosts(doc, { stripeCharge: charge });
|
|
37
|
+
const webhook = (annotated as unknown as { webhooks: Record<string, unknown> }).webhooks.stripeCharge;
|
|
38
|
+
expect(costOf(webhook as never)?.trigger).toBe("webhook-received");
|
|
39
|
+
// basis is UNCHANGED — the metering axis stays orthogonal to the trigger
|
|
40
|
+
expect(costOf(webhook as never)?.components[0].basis).toBe("per-call");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("C024 — the fail-loud attribution disciplines (audit)", () => {
|
|
45
|
+
test("a deferred cost with NO attribution → unattributed-background-cost (warn, never silent)", () => {
|
|
46
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received" } });
|
|
47
|
+
const f = costAudit(annotated).find((x) => x.operation === "stripeCharge");
|
|
48
|
+
expect(f?.code).toBe("unattributed-background-cost");
|
|
49
|
+
expect(f?.message).toContain(UNATTRIBUTED);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a deferred cost reading an UNVERIFIED payload → unverified-attribution (attacker-controllable)", () => {
|
|
53
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received", attribution: { strategy: "event-expression", expression: "{$event.body#/customer}" } } });
|
|
54
|
+
expect(costAudit(annotated).find((x) => x.operation === "stripeCharge")?.code).toBe("unverified-attribution");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a deferred cost with a VERIFIED event attribution is clean (no background finding)", () => {
|
|
58
|
+
const annotated = annotateCosts(doc, { stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received", attribution: { strategy: "event-expression", expression: "{$event.body#/customer}", trust: "verified" } } });
|
|
59
|
+
const codes = costAudit(annotated).filter((x) => x.operation === "stripeCharge").map((x) => x.code);
|
|
60
|
+
expect(codes).not.toContain("unattributed-background-cost");
|
|
61
|
+
expect(codes).not.toContain("unverified-attribution");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a SYNCHRONOUS op is never flagged for attribution (the default path is unchanged)", () => {
|
|
65
|
+
const annotated = annotateCosts(doc, { createOrder: { components: [{ source: "compute", basis: "per-call", microUsd: 10 }] } });
|
|
66
|
+
expect(costAudit(annotated).filter((x) => x.operation === "createOrder").map((x) => x.code)).not.toContain("unattributed-background-cost");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("C024 — costTable surfaces the trigger across paths + webhooks", () => {
|
|
71
|
+
test("the table includes the webhook row with its trigger", () => {
|
|
72
|
+
const annotated = annotateCosts(doc, {
|
|
73
|
+
createOrder: { components: [{ source: "compute", basis: "per-call", microUsd: 10 }] },
|
|
74
|
+
stripeCharge: { components: [{ source: "stripe", basis: "per-call", microUsd: 2900 }], trigger: "webhook-received" },
|
|
75
|
+
});
|
|
76
|
+
const rows = costTable(annotated);
|
|
77
|
+
expect(rows.find((r) => r.operation === "createOrder")?.trigger).toBe("synchronous");
|
|
78
|
+
expect(rows.find((r) => r.operation === "stripeCharge")).toMatchObject({ trigger: "webhook-received", estimateMicroUsd: 2900 });
|
|
79
|
+
});
|
|
80
|
+
});
|