@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.
- package/apps/storefront/.runner-dependency-cruiser.cjs +38 -0
- package/apps/storefront/migrations-apps/0017_commerce_durable_projection.sql +28 -0
- package/apps/storefront/migrations-apps/0018_commerce_provider_cart_identity.sql +13 -0
- package/apps/storefront/package.json +2 -1
- package/apps/storefront/src/config/declaredTenantConfig.ts +5 -0
- package/apps/storefront/src/config/devTenantSeed.ts +3 -2
- package/apps/storefront/src/lib/checkout/commerce/commerceAdmission.ts +108 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +1 -1
- package/apps/storefront/src/lib/checkout/shared/checkoutCommerce.ts +6 -4
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +111 -0
- package/apps/storefront/src/lib/fulfillment/admin/viewmodels/fulfillment.ts +1 -1
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +146 -0
- package/apps/storefront/src/lib/fulfillment/commerceReadback.ts +60 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +65 -0
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +13 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +50 -4
- package/apps/storefront/src/lib/fulfillment/readiness.ts +1 -0
- package/apps/storefront/src/lib/fulfillment/store.ts +15 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +6 -3
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +6 -5
- package/apps/storefront/src/lib/inventory/allocations.ts +58 -9
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +121 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +8 -8
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +22 -1
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +12 -4
- package/apps/storefront/src/pages/api/admin/commerce-readiness.ts +56 -26
- package/apps/storefront/src/pages/api/commerce/checkout-handoff.ts +34 -2
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +158 -306
- package/apps/storefront/src/pages/products/[handle].astro +13 -8
- package/apps/storefront/test/pipeline/harness.ts +6 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +29 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +45 -7
- package/packages/public-runtime/src/checkout.ts +37 -9
- 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
|
|
295
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
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)
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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 = ?
|
|
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
|
|
366
|
+
tone: ["fresh", "not-required"].includes(tax.state) ? "blue" : "amber",
|
|
367
367
|
action: "View total",
|
|
368
|
-
blocking:
|
|
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
|
|
375
|
-
?
|
|
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
|
|
377
|
+
tone: ["fresh", "not-required"].includes(verification.state) && !ageHold ? "green" : "amber",
|
|
378
378
|
action: "View evidence",
|
|
379
|
-
blocking: ageHold ||
|
|
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
|
|
395
|
+
tone: ["fresh", "not-required"].includes(column("risk").state) ? "green" : "amber",
|
|
396
396
|
action: "View evidence",
|
|
397
|
-
blocking: column("risk").state
|
|
397
|
+
blocking: !["fresh", "not-required"].includes(column("risk").state),
|
|
398
398
|
}),
|
|
399
399
|
columnCheck(column("stock"), {
|
|
400
400
|
label: "Stock",
|