@tokenoftrust/storefront-runner 2.4.90 → 2.5.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.
Files changed (35) hide show
  1. package/apps/storefront/.runner-dependency-cruiser.cjs +38 -0
  2. package/apps/storefront/migrations-apps/0017_commerce_durable_projection.sql +28 -0
  3. package/apps/storefront/migrations-apps/0018_commerce_provider_cart_identity.sql +13 -0
  4. package/apps/storefront/package.json +2 -1
  5. package/apps/storefront/src/config/declaredTenantConfig.ts +5 -0
  6. package/apps/storefront/src/config/devTenantSeed.ts +3 -2
  7. package/apps/storefront/src/lib/checkout/commerce/commerceAdmission.ts +108 -0
  8. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +1 -1
  9. package/apps/storefront/src/lib/checkout/shared/checkoutCommerce.ts +6 -4
  10. package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +111 -0
  11. package/apps/storefront/src/lib/fulfillment/admin/viewmodels/fulfillment.ts +1 -1
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +146 -0
  13. package/apps/storefront/src/lib/fulfillment/commerceReadback.ts +60 -0
  14. package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +65 -0
  15. package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +13 -0
  16. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +50 -4
  17. package/apps/storefront/src/lib/fulfillment/readiness.ts +1 -0
  18. package/apps/storefront/src/lib/fulfillment/store.ts +15 -0
  19. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +6 -3
  20. package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +6 -5
  21. package/apps/storefront/src/lib/inventory/allocations.ts +58 -9
  22. package/apps/storefront/src/lib/inventory/mirrorStock.ts +121 -0
  23. package/apps/storefront/src/lib/orders/admin/services/orders.ts +8 -8
  24. package/apps/storefront/src/lib/orders/data/orderMapper.ts +22 -1
  25. package/apps/storefront/src/lib/orders/data/ordersStore.ts +12 -4
  26. package/apps/storefront/src/pages/api/admin/commerce-readiness.ts +56 -26
  27. package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +34 -2
  28. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +158 -306
  29. package/apps/storefront/src/pages/products/[handle].astro +13 -8
  30. package/apps/storefront/test/pipeline/harness.ts +6 -0
  31. package/apps/storefront/test/pipeline/pipeline-host.ts +29 -0
  32. package/package.json +1 -1
  33. package/packages/public-runtime/src/checkout-orders.ts +45 -7
  34. package/packages/public-runtime/src/checkout.ts +37 -9
  35. package/packages/public-runtime/src/tenant.ts +3 -0
@@ -0,0 +1,65 @@
1
+ import { ScrappyOrderLedger } from "../orders/data/ordersStore.js";
2
+ /** Production destination handler: delivery succeeds only after the persisted desk projection succeeds. */
3
+ import { COMMERCE_SCHEDULER, type CommerceStage } from "@tot/public-runtime";
4
+ import type { Queryable } from "../catalog/d1/catalog.js";
5
+ import { sweepDurableCommerce } from "../integrations/webhooks/deliveryEngine.js";
6
+ import { projectCommerceOrder } from "./commerceProjection.js";
7
+
8
+ export async function recoverTenantCommerce(
9
+ db: Queryable,
10
+ tenantId: string,
11
+ now: string,
12
+ deployedSha: string,
13
+ ): Promise<number | null> {
14
+ for (const stage of ["preview", "publication"] as const) {
15
+ const runId = crypto.randomUUID();
16
+ await db.run(
17
+ `INSERT INTO commerce_recovery_runs(run_id,tenant_id,stage,started_at,deployed_sha) VALUES(?,?,?,?,?)`,
18
+ runId,
19
+ tenantId,
20
+ stage,
21
+ now,
22
+ deployedSha,
23
+ );
24
+ const result = await sweepDurableCommerce({
25
+ db,
26
+ scheduler: COMMERCE_SCHEDULER,
27
+ now,
28
+ tenantId,
29
+ stage,
30
+ deliver: async (_event, row) => {
31
+ if (row.stage !== "preview" && row.stage !== "publication") throw new Error("invalid-stage");
32
+ await projectCommerceOrder(db, {
33
+ tenantId: row.tenant_id,
34
+ stage: row.stage as CommerceStage,
35
+ orderId: row.order_id,
36
+ now,
37
+ });
38
+ },
39
+ });
40
+ if (!result.ok) throw new Error("scheduler-unavailable");
41
+ await db.run(
42
+ `UPDATE commerce_recovery_runs SET completed_at=?,delivered=?,retried=?,dead=? WHERE run_id=?`,
43
+ now,
44
+ result.delivered,
45
+ result.retried,
46
+ result.dead,
47
+ runId,
48
+ );
49
+ }
50
+ const due = await db.first<{ available_at: string }>(
51
+ `SELECT MIN(available_at) available_at FROM scrappy_commerce_outbox WHERE tenant_id=? AND state='pending'`,
52
+ tenantId,
53
+ );
54
+ return due?.available_at ? Math.max(Date.parse(now) + 1000, Date.parse(due.available_at)) : null;
55
+ }
56
+
57
+ export async function replayCommerceOrder(
58
+ db: Queryable,
59
+ tenantId: string,
60
+ stage: CommerceStage,
61
+ outboxId: string,
62
+ now: string,
63
+ ): Promise<boolean> {
64
+ return new ScrappyOrderLedger(db).replayOutbox(tenantId, stage, outboxId, now);
65
+ }
@@ -0,0 +1,13 @@
1
+ /** Commerce reaches the existing tenant scheduler through its own narrow RPC interface. */
2
+ import { getWorkerEnv } from "../publication/shared/runtimeEnv.js";
3
+ import type { CommerceCallerConfig, SignedCommerceResult } from "../orders/data/orderForwardReceiver.js";
4
+ export interface CommercePipeline {
5
+ commerceReceive(body: unknown, token: string, config: CommerceCallerConfig): Promise<SignedCommerceResult>;
6
+ commerceReplay(tenantId: string, stage: "preview" | "publication", outboxId: string): Promise<boolean>;
7
+ }
8
+ export async function commercePipelineFor(tenantId: string): Promise<CommercePipeline | null> {
9
+ const namespace = (await getWorkerEnv())?.TENANT_PIPELINE as
10
+ | { idFromName(name: string): unknown; get(id: unknown): CommercePipeline }
11
+ | undefined;
12
+ return namespace && typeof namespace.idFromName === "function" ? namespace.get(namespace.idFromName(tenantId)) : null;
13
+ }
@@ -61,6 +61,8 @@ export interface FulfillmentActor extends CommerceActor {
61
61
  }
62
62
 
63
63
  export interface SourceOrderSnapshot {
64
+ sourceVersion?: number;
65
+ evidenceExpiresAt?: string;
64
66
  tenantId: string;
65
67
  stage: CommerceStage;
66
68
  orderId: string;
@@ -117,6 +119,10 @@ export interface FulfillmentView {
117
119
  batch: { batchId: string; phase: BatchPhase } | null;
118
120
  shipment: ShipmentIntentView | null;
119
121
  packingSlip: PackingSlip | null;
122
+ lines: {sku:string;quantity:number}[];
123
+ evidence: CommerceEvidence;
124
+ placedAt: string;
125
+ currency: string;
120
126
  }
121
127
 
122
128
  export interface FulfillmentBatch {
@@ -291,9 +297,9 @@ function observedEvent(input: {
291
297
  }
292
298
 
293
299
  function mergePayment(local: PaymentState | null, source: PaymentState): PaymentState {
294
- if (local === "refund_requested" && (source === "captured" || source === "authorized" || source === "pending")) {
295
- return "refund_requested";
296
- }
300
+ if (local && ["refund_requested", "refund_unknown", "refunded", "voided"].includes(local) &&
301
+ ["UNKNOWN", "pending", "authorized", "captured", "capture_unknown"].includes(source)) return local;
302
+ if (local === "refunded") return local;
297
303
  return source;
298
304
  }
299
305
 
@@ -542,6 +548,19 @@ export class FulfillmentService {
542
548
  return state ? ok(state.view) : refuse("not-found");
543
549
  }
544
550
 
551
+ async list(actor: FulfillmentActor, input: {limit:number;cursor:string|null}): Promise<FulfillmentResult<{orders:FulfillmentView[];nextCursor:string|null}>> {
552
+ const scope=this.commandScope(actor);
553
+ if (!scope) return refuse("denied");
554
+ const limit=Math.max(1,Math.min(50,input.limit));
555
+ const cursor=input.cursor ?? "";
556
+ if (cursor && !isOpaqueId(cursor)) return refuse("malformed");
557
+ const rows=await this.db.all<{order_id:string}>(`SELECT order_id FROM scrappy_commerce_orders
558
+ WHERE tenant_id=? AND stage=? AND order_id>? AND source != 'scrappy' ORDER BY order_id LIMIT ?`,[scope.tenantId,scope.stage,cursor,limit+1]);
559
+ const selected=rows.slice(0,limit); const orders:FulfillmentView[]=[];
560
+ for (const row of selected) { const state=await this.load(scope.tenantId,scope.stage,row.order_id); if(state) orders.push(state.view); }
561
+ return ok({orders,nextCursor:rows.length>limit ? selected[selected.length-1]?.order_id ?? null:null});
562
+ }
563
+
545
564
  async batch(actor: FulfillmentActor, batchId: string): Promise<FulfillmentResult<FulfillmentBatch>> {
546
565
  const scope = this.commandScope(actor);
547
566
  if (!scope) return refuse("denied");
@@ -980,6 +999,7 @@ export class FulfillmentService {
980
999
  const wantSlip = phase === "packed" && !state.packingSlip;
981
1000
  const readiness = this.readiness(state);
982
1001
  const releasable = canRelease(readiness);
1002
+ if (raising && !releasable) return {type:"refuse",code:releaseHolds(readiness)[0] ?? "held",holds:releaseHolds(readiness)};
983
1003
  const labelAcceptsIntent = state.order.label === "none" || state.order.label === "voided" || state.order.label === "intent";
984
1004
  const wantIntent = (phase === null || phase === "picked" || phase === "packed") && releasable && !state.shipment && labelAcceptsIntent;
985
1005
  if (phase === null && !state.shipment && !wantIntent) {
@@ -1170,6 +1190,7 @@ export class FulfillmentService {
1170
1190
  eventVersion: version,
1171
1191
  },
1172
1192
  };
1193
+ if (snapshot.evidenceExpiresAt) statements.push(gatedAudit(stub, actor.actorId, "evidence-expires", "none", snapshot.evidenceExpiresAt, stamp(this.now(), 6), gate, gateParams));
1173
1194
  statements.push(
1174
1195
  gatedAudit(stub, actor.actorId, "evidence", "none", jsonText(snapshot.evidence), at, gate, gateParams),
1175
1196
  gatedAudit(stub, actor.actorId, "lines", "none", jsonText(snapshot.lines), stamp(this.now(), 2), gate, gateParams),
@@ -1199,6 +1220,7 @@ export class FulfillmentService {
1199
1220
  gateParams,
1200
1221
  ),
1201
1222
  );
1223
+ if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,version,this.now()));
1202
1224
  const changes = await this.write(statements);
1203
1225
  if (!changes) return refuse("unavailable");
1204
1226
  if ((changes[0] ?? 0) !== 1) return "exists";
@@ -1211,7 +1233,11 @@ export class FulfillmentService {
1211
1233
  _existing: LoadedOrder,
1212
1234
  snapshot: SourceOrderSnapshot,
1213
1235
  ): Promise<FulfillmentResult<FulfillmentView>> {
1214
- return this.commit(actor, snapshot.orderId, undefined, (state) => {
1236
+ return this.commit(actor, snapshot.orderId, undefined, async (state) => {
1237
+ if (snapshot.sourceVersion !== undefined) {
1238
+ const checkpoint=await this.db.get<{source_version:number}>(`SELECT source_version FROM commerce_projection_checkpoints WHERE tenant_id=? AND stage=? AND order_id=?`,[snapshot.tenantId,snapshot.stage,snapshot.orderId]);
1239
+ if (checkpoint && Number(checkpoint.source_version)>=snapshot.sourceVersion) return {type:"done",view:state.view};
1240
+ }
1215
1241
  const payment = mergePayment(state.order.payment, snapshot.payment);
1216
1242
  const label = mergeLabel(state.order.label, snapshot.label);
1217
1243
  const confirmedCancel = snapshot.fulfillment === "cancelled" || payment === "voided" || payment === "refunded";
@@ -1243,6 +1269,10 @@ export class FulfillmentService {
1243
1269
  ];
1244
1270
  let n = state.auditCount;
1245
1271
  const nextVersion = version;
1272
+ if(snapshot.evidenceExpiresAt) {
1273
+ n+=1; statements.push(auditStatement(state,actor.actorId,"evidence-expires","source",snapshot.evidenceExpiresAt,version,stamp(this.now(),n)));
1274
+ }
1275
+
1246
1276
  n += 1;
1247
1277
  statements.push(auditStatement(state, actor.actorId, "evidence", "source", jsonText(snapshot.evidence), nextVersion, stamp(this.now(), n)));
1248
1278
  n += 1;
@@ -1308,6 +1338,7 @@ export class FulfillmentService {
1308
1338
  n += 1;
1309
1339
  statements.push(auditStatement(state, actor.actorId, "pending-cancel", "open", "confirmed", nextVersion, stamp(this.now(), n)));
1310
1340
  }
1341
+ if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,nextVersion,this.now()));
1311
1342
  return { type: "write", statements, minimumChanges: 1 };
1312
1343
  });
1313
1344
  }
@@ -1354,6 +1385,7 @@ export class FulfillmentService {
1354
1385
  [tenantId, stage, orderId],
1355
1386
  );
1356
1387
  let evidence = FAIL_CLOSED_EVIDENCE;
1388
+ let evidenceExpiresAt:string|null=null;
1357
1389
  let lines: { sku: string; quantity: number }[] = [];
1358
1390
  let totals: MinorTotals | null = null;
1359
1391
  let batch: { batchId: string; phase: BatchPhase } | null = null;
@@ -1366,6 +1398,7 @@ export class FulfillmentService {
1366
1398
  let intentAdult = this.policy.adultSignatureRequired;
1367
1399
  for (const audit of audits) {
1368
1400
  if (audit.kind === "evidence") evidence = parseEvidence(parseJson(audit.to_state)) ?? FAIL_CLOSED_EVIDENCE;
1401
+ else if (audit.kind === "evidence-expires") evidenceExpiresAt=audit.to_state;
1369
1402
  else if (audit.kind === "lines") lines = parseLines(parseJson(audit.to_state)) ?? [];
1370
1403
  else if (audit.kind === "totals") totals = parseTotals(parseJson(audit.to_state));
1371
1404
  else if (audit.kind === "batch") batch = parseBatchState(audit.to_state);
@@ -1384,6 +1417,9 @@ export class FulfillmentService {
1384
1417
  }
1385
1418
  }
1386
1419
  }
1420
+ if(evidenceExpiresAt && (!Number.isFinite(Date.parse(evidenceExpiresAt)) || Date.parse(evidenceExpiresAt)<=Date.parse(this.now()))) {
1421
+ evidence={verification:"stale",tax:"stale",risk:"stale",stock:"stale"};
1422
+ }
1387
1423
  const provider = pickProvider(providers);
1388
1424
  const order = {
1389
1425
  tenantId,
@@ -1435,6 +1471,7 @@ export class FulfillmentService {
1435
1471
  batch,
1436
1472
  shipment: null,
1437
1473
  packingSlip,
1474
+ lines, evidence, placedAt:order.placedAt, currency:order.currency,
1438
1475
  },
1439
1476
  };
1440
1477
  const readiness = this.readiness(loaded);
@@ -1564,6 +1601,7 @@ function projectedFromSnapshot(
1564
1601
  cancelPending: boolean,
1565
1602
  ): FulfillmentState {
1566
1603
  if (current === "handed_off") return "handed_off";
1604
+ if (current === "cancelled") return "cancelled";
1567
1605
  if (snapshot.fulfillment === "cancelled" || payment === "voided" || payment === "refunded") return "cancelled";
1568
1606
  if (snapshot.fulfillment === "UNKNOWN") return "UNKNOWN";
1569
1607
  const holds = releaseHolds({
@@ -1650,3 +1688,11 @@ function pickProvider(rows: readonly { purchase_id: string; outcome: string }[])
1650
1688
  if (!chosen || !oneOf(chosen.outcome, ["intent", "accepted", "unknown", "voided", "refunded"] as const)) return null;
1651
1689
  return { purchaseId: chosen.purchase_id, outcome: chosen.outcome };
1652
1690
  }
1691
+
1692
+ function projectionCheckpoint(snapshot:SourceOrderSnapshot,version:number,at:string):SqlStatement {
1693
+ return {sql:`INSERT INTO commerce_projection_checkpoints(tenant_id,stage,order_id,source_version,projected_at)
1694
+ SELECT ?,?,?,?,? WHERE EXISTS(SELECT 1 FROM scrappy_commerce_orders WHERE tenant_id=? AND stage=? AND order_id=? AND event_version=?)
1695
+ ON CONFLICT(tenant_id,stage,order_id) DO UPDATE SET source_version=excluded.source_version,projected_at=excluded.projected_at
1696
+ WHERE excluded.source_version>commerce_projection_checkpoints.source_version`,
1697
+ params:[snapshot.tenantId,snapshot.stage,snapshot.orderId,snapshot.sourceVersion,at,snapshot.tenantId,snapshot.stage,snapshot.orderId,version]};
1698
+ }
@@ -62,6 +62,7 @@ export interface ReadinessInput {
62
62
 
63
63
  const EVIDENCE_HOLDS: Record<EvidenceState, ReleaseHold | null> = {
64
64
  fresh: null,
65
+ "not-required": null,
65
66
  UNKNOWN: "evidence-unknown",
66
67
  stale: "evidence-stale",
67
68
  unreadable: "evidence-unreadable",
@@ -107,3 +107,18 @@ export function fulfillmentDbFromD1(db: D1Like): FulfillmentDatabase {
107
107
  },
108
108
  };
109
109
  }
110
+
111
+ /** Reuses Queryable's transactional D1 batch and keeps affected-row checks inside that transaction. */
112
+ export function fulfillmentDbFromQueryable(db: import("../catalog/d1/catalog.js").Queryable): FulfillmentDatabase {
113
+ return {
114
+ get: async (sql,params=[]) => (await db.first(sql,...params) ?? undefined) as never,
115
+ all: (sql,params=[]) => db.all(sql,...params),
116
+ async batch(statements) {
117
+ if (!db.batchResults) throw new Error("transaction-results-required");
118
+ const results=await db.batchResults(statements.flatMap(statement=>[
119
+ {sql:statement.sql,params:statement.params},{sql:"SELECT changes() AS affected"},
120
+ ]));
121
+ return statements.map((_,index)=>Number(results[index*2+1]?.results[0]?.affected ?? 0));
122
+ },
123
+ };
124
+ }
@@ -328,7 +328,7 @@ export type CommerceSweepResult =
328
328
  | { ok: true; delivered: number; retried: number; dead: number }
329
329
  | { ok: false; reason: "scheduler" };
330
330
 
331
- export type CommerceDeliver = (event: Record<string, unknown>) => Promise<void>;
331
+ export type CommerceDeliver = (event: Record<string, unknown>, row: CommerceOutboxDue) => Promise<void>;
332
332
 
333
333
  /**
334
334
  * Deliver due commerce outbox rows. A scheduler other than the declared alarm
@@ -339,10 +339,12 @@ export async function sweepDurableCommerce(input: {
339
339
  scheduler: string;
340
340
  now: string;
341
341
  deliver?: CommerceDeliver;
342
+ tenantId?: string;
343
+ stage?: string;
342
344
  }): Promise<CommerceSweepResult> {
343
345
  if (input.scheduler !== COMMERCE_SCHEDULER) return { ok: false, reason: "scheduler" };
344
346
  const port = new ScrappyCommerceOutbox(input.db);
345
- const due = await port.due(input.now);
347
+ const due = await port.due(input.now,input.tenantId,input.stage);
346
348
  let delivered = 0;
347
349
  let retried = 0;
348
350
  let dead = 0;
@@ -375,7 +377,8 @@ async function settleCommerceRow(
375
377
  return "dead";
376
378
  }
377
379
  try {
378
- if (deliver) await deliver(event);
380
+ if (!deliver) throw new Error("delivery-unconfigured");
381
+ await deliver(event,row);
379
382
  await port.markDelivered(row.outbox_id);
380
383
  return "delivered";
381
384
  } catch {
@@ -667,6 +667,7 @@ export class WebhookDeliveryStore {
667
667
  }
668
668
 
669
669
  export interface CommerceOutboxDue {
670
+ order_id: string;
670
671
  outbox_id: string;
671
672
  tenant_id: string;
672
673
  stage: string;
@@ -679,13 +680,13 @@ export interface CommerceOutboxDue {
679
680
  export class ScrappyCommerceOutbox {
680
681
  constructor(private readonly db: Queryable) {}
681
682
 
682
- async due(now: string): Promise<CommerceOutboxDue[]> {
683
+ async due(now: string, tenantId?: string, stage?: string): Promise<CommerceOutboxDue[]> {
683
684
  const rows = await this.db.all<CommerceOutboxDue>(
684
- `SELECT outbox_id, tenant_id, stage, payload, attempt_count, available_at
685
+ `SELECT outbox_id, tenant_id, stage, order_id, payload, attempt_count, available_at
685
686
  FROM scrappy_commerce_outbox
686
- WHERE state = 'pending' AND available_at <= ?
687
- ORDER BY available_at`,
688
- now,
687
+ WHERE state = 'pending' AND available_at <= ? AND (? IS NULL OR tenant_id=?) AND (? IS NULL OR stage=?)
688
+ ORDER BY available_at LIMIT 100`,
689
+ now, tenantId ?? null, tenantId ?? null, stage ?? null, stage ?? null,
689
690
  );
690
691
  return rows.map((row) => ({ ...row, attempt_count: Number(row.attempt_count) }));
691
692
  }
@@ -22,6 +22,7 @@ export const VERIFICATION_PROVIDER_OWNER = "tot-9577-tot-verification-provider";
22
22
 
23
23
  const SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
24
24
  const ID_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
25
+ const TENANT_RE = /^[a-z0-9][a-z0-9.-]{0,253}$/;
25
26
  const LOCATION_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,80}$/;
26
27
  const ISSUER_RE = /^[A-Za-z0-9._:/?#&=%+-]{1,200}$/;
27
28
  const JTI_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,128}$/;
@@ -83,11 +84,15 @@ export interface ReserveInput {
83
84
  issuer: string;
84
85
  jti: string;
85
86
  holds: readonly ReserveHold[];
87
+ /** Trusted server snapshot committed in the same transaction as the stock holds. */
88
+ admissionStatement?: SqlStatement;
89
+ /** Immutable provider identity, claimed atomically with its receipt and holds. */
90
+ providerCart?: { provider: "foxycart"; id: string; digest: string; expectedAuthorizationId?: string };
86
91
  }
87
92
 
88
93
  export type ReserveResult =
89
94
  | { ok: true; authorizationId: string; repeated: boolean; expiresAt: string }
90
- | { ok: false; finding: "caller-replay" | "stock-unavailable" | "malformed-request" };
95
+ | { ok: false; finding: "caller-replay" | "stock-unavailable" | "malformed-request" | "provider-cart-conflict" | "admission-consumed" | "admission-expired" };
91
96
 
92
97
  export interface PolicyResult {
93
98
  released: number;
@@ -235,7 +240,7 @@ function text(row: Record<string, unknown>, key: string): string {
235
240
  }
236
241
 
237
242
  function reserveShape(input: ReserveInput): ReserveResult | null {
238
- if (!ID_RE.test(input.tenantId) || !ID_RE.test(input.orderId)) return { ok: false, finding: "malformed-request" };
243
+ if (!TENANT_RE.test(input.tenantId) || !ID_RE.test(input.orderId)) return { ok: false, finding: "malformed-request" };
239
244
  if (input.stage !== "preview" && input.stage !== "publication") return { ok: false, finding: "malformed-request" };
240
245
  if (!DIGEST_RE.test(input.cartDigest) || !/^[A-Z]{3}$/.test(input.currency)) return { ok: false, finding: "malformed-request" };
241
246
  if (!ISSUER_RE.test(input.issuer) || !JTI_RE.test(input.jti)) return { ok: false, finding: "malformed-request" };
@@ -256,7 +261,10 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
256
261
  WHERE (
257
262
  SELECT COALESCE(SUM(quantity), 0) FROM scrappy_allocations
258
263
  WHERE tenant_id = ? AND stage = ? AND sku = ? AND location_id = ? AND state IN ('reserved', 'confirmed')
259
- ) + ? <= ?
264
+ ) + ? <= ${input.admissionStatement
265
+ ? `(SELECT COALESCE(SUM(quantity_delta), 0) FROM commerce_inventory_movements
266
+ WHERE tenant_id = ? AND stage = ? AND sku = ? AND location_id = ?)`
267
+ : "?"}
260
268
  AND NOT EXISTS (
261
269
  SELECT 1 FROM scrappy_allocations
262
270
  WHERE tenant_id = ? AND stage = ? AND order_id = ? AND sku = ? AND location_id = ?
@@ -274,6 +282,7 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
274
282
  SELECT 1 FROM scrappy_commerce_orders
275
283
  WHERE tenant_id = ? AND stage = ? AND order_id = ?
276
284
  )
285
+ ${input.providerCart ? `AND EXISTS (SELECT 1 FROM commerce_provider_carts WHERE tenant_id=? AND stage=? AND provider=? AND provider_cart_id=? AND authorization_id=? AND provider_cart_digest=?)` : ""}
277
286
  ON CONFLICT DO NOTHING
278
287
  RETURNING order_id`,
279
288
  params: [
@@ -288,7 +297,9 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
288
297
  hold.sku,
289
298
  hold.locationId,
290
299
  hold.quantity,
291
- hold.onHand,
300
+ ...(input.admissionStatement
301
+ ? [input.tenantId, input.stage, hold.sku, hold.locationId]
302
+ : [hold.onHand]),
292
303
  input.tenantId,
293
304
  input.stage,
294
305
  input.orderId,
@@ -305,6 +316,7 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
305
316
  input.tenantId,
306
317
  input.stage,
307
318
  input.orderId,
319
+ ...(input.providerCart ? [input.tenantId,input.stage,input.providerCart.provider,input.providerCart.id,input.orderId,input.providerCart.digest] : []),
308
320
  ],
309
321
  };
310
322
  }
@@ -313,6 +325,10 @@ function allocationInsert(input: ReserveInput, hold: ReserveHold, seenAt: string
313
325
  export async function reserveAllocation(runner: StatementRunner, input: ReserveInput): Promise<ReserveResult> {
314
326
  const malformed = reserveShape(input);
315
327
  if (malformed) return malformed;
328
+ const cart = input.providerCart;
329
+ if (!!cart !== !!input.admissionStatement) return { ok: false, finding: "malformed-request" };
330
+ if (cart && (!input.admissionStatement || cart.provider !== "foxycart" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(cart.id) || !DIGEST_RE.test(cart.digest) ||
331
+ (cart.expectedAuthorizationId !== undefined && !ID_RE.test(cart.expectedAuthorizationId)))) return { ok: false, finding: "malformed-request" };
316
332
  const seen = await runner.all(
317
333
  `SELECT jti FROM scrappy_caller_replay WHERE tenant_id = ? AND stage = ? AND issuer = ? AND jti = ?`,
318
334
  [input.tenantId, input.stage, input.issuer, input.jti],
@@ -339,6 +355,12 @@ export async function reserveAllocation(runner: StatementRunner, input: ReserveI
339
355
  RETURNING order_id`,
340
356
  params: [input.tenantId, input.stage, input.orderId, input.orderId, input.currency, input.now, input.amountMinor],
341
357
  },
358
+ ...(cart ? [{
359
+ sql: `INSERT INTO commerce_provider_carts (tenant_id,stage,provider,provider_cart_id,provider_cart_digest,authorization_id)
360
+ SELECT ?,?,?,?,?,? WHERE ?='' AND EXISTS (SELECT 1 FROM scrappy_caller_replay WHERE tenant_id=? AND stage=? AND issuer=? AND jti=? AND seen_at=?)
361
+ ON CONFLICT DO NOTHING RETURNING authorization_id`,
362
+ params: [input.tenantId,input.stage,cart.provider,cart.id,cart.digest,input.orderId,cart.expectedAuthorizationId ?? "",input.tenantId,input.stage,input.issuer,input.jti,seenAt],
363
+ }] : []),
342
364
  ...holds.map((hold) => allocationInsert(input, hold, seenAt)),
343
365
  {
344
366
  sql: `INSERT INTO scrappy_final_cart_authorizations (
@@ -379,13 +401,14 @@ export async function reserveAllocation(runner: StatementRunner, input: ReserveI
379
401
  seenAt,
380
402
  ],
381
403
  },
404
+ ...(input.admissionStatement ? [input.admissionStatement] : []),
382
405
  {
383
406
  sql: `UPDATE scrappy_allocations
384
407
  SET state = 'released'
385
408
  WHERE tenant_id = ? AND stage = ? AND order_id = ? AND state = 'reserved'
386
409
  AND NOT EXISTS (
387
410
  SELECT 1 FROM scrappy_final_cart_authorizations
388
- WHERE tenant_id = ? AND stage = ? AND authorization_id = ? AND state = 'active'
411
+ WHERE tenant_id = ? AND stage = ? AND authorization_id = ? AND state IN ('active', 'consumed')
389
412
  )
390
413
  RETURNING quantity`,
391
414
  params: [input.tenantId, input.stage, input.orderId, input.tenantId, input.stage, input.orderId],
@@ -410,10 +433,30 @@ export async function reserveAllocation(runner: StatementRunner, input: ReserveI
410
433
  },
411
434
  ];
412
435
 
436
+ if (cart) statements.push({
437
+ sql: `DELETE FROM commerce_provider_carts WHERE tenant_id=? AND stage=? AND provider=? AND provider_cart_id=? AND authorization_id=?
438
+ AND NOT EXISTS (SELECT 1 FROM commerce_admission_snapshots WHERE tenant_id=? AND stage=? AND authorization_id=?)`,
439
+ params: [input.tenantId,input.stage,cart.provider,cart.id,input.orderId,input.tenantId,input.stage,input.orderId],
440
+ }, {
441
+ sql: `SELECT p.authorization_id,p.provider_cart_digest,a.expires_at,a.state,s.authorization_id receipt_id FROM commerce_provider_carts p
442
+ LEFT JOIN scrappy_final_cart_authorizations a ON a.tenant_id=p.tenant_id AND a.stage=p.stage AND a.authorization_id=p.authorization_id
443
+ LEFT JOIN commerce_admission_snapshots s ON s.tenant_id=p.tenant_id AND s.stage=p.stage AND s.authorization_id=p.authorization_id
444
+ WHERE p.tenant_id=? AND p.stage=? AND p.provider=? AND p.provider_cart_id=?`,
445
+ params: [input.tenantId,input.stage,cart.provider,cart.id],
446
+ });
413
447
  const results = await runner.batch(statements);
414
448
  const replayRows = results[0] ?? [];
415
- const authRows = results[2 + holds.length] ?? [];
449
+ const authRows = results[2 + holds.length + (cart ? 1 : 0)] ?? [];
416
450
  const selected = results[results.length - 1] ?? [];
451
+ if (cart && replayRows.length > 0) {
452
+ const current = selected[0];
453
+ if (!current) return { ok: false, finding: cart.expectedAuthorizationId ? "provider-cart-conflict" : "stock-unavailable" };
454
+ if (text(current,"provider_cart_digest") !== cart.digest || (cart.expectedAuthorizationId && text(current,"authorization_id") !== cart.expectedAuthorizationId)) return { ok: false, finding: "provider-cart-conflict" };
455
+ if (!text(current,"receipt_id")) return { ok: false, finding: "provider-cart-conflict" };
456
+ if (text(current,"state") !== "active") return { ok: false, finding: "admission-consumed" };
457
+ if (text(current,"expires_at") <= input.now) return { ok: false, finding: "admission-expired" };
458
+ return { ok: true, authorizationId: text(current,"authorization_id"), repeated: authRows.length === 0, expiresAt: text(current,"expires_at") };
459
+ }
417
460
  if (authRows.length > 0) {
418
461
  return { ok: true, authorizationId: input.orderId, repeated: false, expiresAt: input.expiresAt };
419
462
  }
@@ -446,9 +489,11 @@ export async function releaseHoldOnce(
446
489
  {
447
490
  sql: `UPDATE scrappy_final_cart_authorizations
448
491
  SET state = ?
449
- WHERE tenant_id = ? AND stage = ? AND authorization_id = ? AND state IN ('active', 'consumed')
492
+ WHERE tenant_id = ? AND stage = ? AND (authorization_id = ? OR authorization_id IN (
493
+ SELECT authorization_id FROM commerce_admission_snapshots WHERE tenant_id=? AND stage=? AND linked_order_id=?
494
+ )) AND state IN ('active', 'consumed')
450
495
  RETURNING authorization_id`,
451
- params: [input.authState, ...scope],
496
+ params: [input.authState, ...scope, ...scope],
452
497
  },
453
498
  {
454
499
  sql: `UPDATE scrappy_allocations
@@ -529,7 +574,11 @@ export async function releaseOrphanAllocations(
529
574
  SELECT 1 FROM scrappy_final_cart_authorizations AS auth
530
575
  WHERE auth.tenant_id = scrappy_allocations.tenant_id
531
576
  AND auth.stage = scrappy_allocations.stage
532
- AND auth.authorization_id = scrappy_allocations.order_id
577
+ AND (auth.authorization_id = scrappy_allocations.order_id OR EXISTS (
578
+ SELECT 1 FROM commerce_admission_snapshots admission WHERE admission.tenant_id=auth.tenant_id
579
+ AND admission.stage=auth.stage AND admission.authorization_id=auth.authorization_id
580
+ AND admission.linked_order_id=scrappy_allocations.order_id
581
+ ))
533
582
  AND auth.state IN ('active', 'consumed')
534
583
  )
535
584
  RETURNING quantity`,
@@ -0,0 +1,121 @@
1
+ /** Tenant-scoped inventory authority. Catalog availability never supplies a balance. */
2
+ import type { Queryable } from "../catalog/d1/catalog.js";
3
+ import type { CommerceStage } from "@tot/public-runtime";
4
+ import type { MirrorStockAuthority } from "./allocations.js";
5
+
6
+ const ID = /^[A-Za-z0-9._:-]{1,128}$/;
7
+ /** Stable caller-visible failures; storage errors retain no public text. */
8
+ export class InventoryMovementError extends Error {
9
+ constructor(readonly code: "invalid-movement" | "conflict") {
10
+ super(code === "conflict" ? "inventory-event-conflict" : "invalid-inventory-movement");
11
+ this.name = "InventoryMovementError";
12
+ }
13
+ }
14
+
15
+ export interface MirrorMovement {
16
+ tenantId: string;
17
+ stage: CommerceStage;
18
+ source: string;
19
+ sourceEventId: string;
20
+ sku: string;
21
+ locationId: string;
22
+ quantityDelta: number;
23
+ asOf: string;
24
+ actorId: string;
25
+ }
26
+ export async function appendMirrorMovement(db: Queryable, input: MirrorMovement): Promise<void> {
27
+ if (
28
+ ![input.tenantId, input.source, input.sourceEventId, input.sku, input.locationId, input.actorId].every((v) =>
29
+ ID.test(v),
30
+ ) ||
31
+ !Number.isSafeInteger(input.quantityDelta) ||
32
+ !Number.isFinite(Date.parse(input.asOf)) ||
33
+ !["preview", "publication"].includes(input.stage)
34
+ )
35
+ throw new InventoryMovementError("invalid-movement");
36
+ const prior = await db.first<{ quantity_delta: number; as_of: string; actor_id: string }>(
37
+ `SELECT quantity_delta,as_of,actor_id FROM commerce_inventory_movements
38
+ WHERE tenant_id=? AND stage=? AND source=? AND source_event_id=? AND sku=? AND location_id=?`,
39
+ input.tenantId,
40
+ input.stage,
41
+ input.source,
42
+ input.sourceEventId,
43
+ input.sku,
44
+ input.locationId,
45
+ );
46
+ if (
47
+ prior &&
48
+ (Number(prior.quantity_delta) !== input.quantityDelta ||
49
+ prior.as_of !== input.asOf ||
50
+ prior.actor_id !== input.actorId)
51
+ )
52
+ throw new InventoryMovementError("conflict");
53
+ await db.run(
54
+ `INSERT INTO commerce_inventory_movements
55
+ (tenant_id,stage,source,source_event_id,sku,location_id,quantity_delta,as_of,actor_id)
56
+ VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`,
57
+ input.tenantId,
58
+ input.stage,
59
+ input.source,
60
+ input.sourceEventId,
61
+ input.sku,
62
+ input.locationId,
63
+ input.quantityDelta,
64
+ input.asOf,
65
+ input.actorId,
66
+ );
67
+ const actual = await db.first<{ quantity_delta: number; as_of: string; actor_id: string }>(
68
+ `SELECT quantity_delta,as_of,actor_id FROM commerce_inventory_movements WHERE tenant_id=? AND stage=? AND source=? AND source_event_id=? AND sku=? AND location_id=?`,
69
+ input.tenantId,
70
+ input.stage,
71
+ input.source,
72
+ input.sourceEventId,
73
+ input.sku,
74
+ input.locationId,
75
+ );
76
+ if (
77
+ !actual ||
78
+ Number(actual.quantity_delta) !== input.quantityDelta ||
79
+ actual.as_of !== input.asOf ||
80
+ actual.actor_id !== input.actorId
81
+ )
82
+ throw new InventoryMovementError("conflict");
83
+ }
84
+ export async function readMirrorStock(
85
+ db: Queryable,
86
+ input: {
87
+ tenantId: string;
88
+ stage: CommerceStage;
89
+ sku: string;
90
+ locationId: string;
91
+ now: string;
92
+ maxAgeMs: number;
93
+ },
94
+ ): Promise<MirrorStockAuthority | null> {
95
+ const row = await db.first<{ balance: number; as_of: string }>(
96
+ `SELECT SUM(quantity_delta) balance, MAX(as_of) as_of FROM commerce_inventory_movements
97
+ WHERE tenant_id=? AND stage=? AND sku=? AND location_id=?`,
98
+ input.tenantId,
99
+ input.stage,
100
+ input.sku,
101
+ input.locationId,
102
+ );
103
+ if (!row || !row.as_of || !Number.isSafeInteger(Number(row.balance)) || Number(row.balance) < 0) return null;
104
+ const age = Date.parse(input.now) - Date.parse(row.as_of);
105
+ if (
106
+ !Number.isFinite(age) ||
107
+ age < 0 ||
108
+ !Number.isFinite(input.maxAgeMs) ||
109
+ input.maxAgeMs < 0 ||
110
+ age > input.maxAgeMs
111
+ )
112
+ return null;
113
+ return {
114
+ provider: "mirror",
115
+ readable: true,
116
+ sku: input.sku,
117
+ locationId: input.locationId,
118
+ onHand: Number(row.balance),
119
+ asOf: row.as_of,
120
+ };
121
+ }
@@ -363,20 +363,20 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
363
363
  label: "Tax and fees",
364
364
  value: tax.state,
365
365
  detail: `Tax evidence is ${tax.state}.`,
366
- tone: tax.state === "fresh" ? "blue" : "amber",
366
+ tone: ["fresh", "not-required"].includes(tax.state) ? "blue" : "amber",
367
367
  action: "View total",
368
- blocking: tax.state !== "fresh",
368
+ blocking: !["fresh", "not-required"].includes(tax.state),
369
369
  },
370
370
  {
371
371
  key: "identity_and_age",
372
372
  label: "Identity and age",
373
373
  value: ageHold ? "Needs review" : verification.state,
374
- detail: verification.state === "fresh"
375
- ? "Verification evidence is fresh."
374
+ detail: ["fresh", "not-required"].includes(verification.state)
375
+ ? `Verification evidence is ${verification.state}.`
376
376
  : "Verification evidence does not clear this order.",
377
- tone: verification.state === "fresh" && !ageHold ? "green" : "amber",
377
+ tone: ["fresh", "not-required"].includes(verification.state) && !ageHold ? "green" : "amber",
378
378
  action: "View evidence",
379
- blocking: ageHold || verification.state !== "fresh",
379
+ blocking: ageHold || !["fresh", "not-required"].includes(verification.state),
380
380
  },
381
381
  {
382
382
  key: "address",
@@ -392,9 +392,9 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
392
392
  columnCheck(column("risk"), {
393
393
  label: "Risk",
394
394
  detail: `Risk evidence is ${column("risk").state}.`,
395
- tone: column("risk").state === "fresh" ? "green" : "amber",
395
+ tone: ["fresh", "not-required"].includes(column("risk").state) ? "green" : "amber",
396
396
  action: "View evidence",
397
- blocking: column("risk").state !== "fresh",
397
+ blocking: !["fresh", "not-required"].includes(column("risk").state),
398
398
  }),
399
399
  columnCheck(column("stock"), {
400
400
  label: "Stock",