@tokenoftrust/storefront-runner 2.4.51 → 2.4.52

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.
@@ -17,7 +17,12 @@ import type {
17
17
  CheckoutOrderDetailResponse,
18
18
  CheckoutOrderListFilters,
19
19
  CheckoutOrderListResponse,
20
+ CommerceActor,
21
+ CommerceRole,
22
+ CommerceScope,
23
+ CommerceStage,
20
24
  } from "@tot/public-runtime";
25
+ import { readOwnerAddress } from "@tot/public-runtime";
21
26
  import type { CustomizationEnv } from "@tot/public-runtime";
22
27
 
23
28
  /** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
@@ -75,24 +80,53 @@ export function orderApiBaseUrl(env: CustomizationEnv): string {
75
80
  : "https://foxycart-test.tokenoftrust.com";
76
81
  }
77
82
 
78
- /** Map an HTTP status + endpoint body to a stable OrderApiError code. */
83
+ /**
84
+ * Map an HTTP status to a stable code. The body is scanned for a known code
85
+ * only. It is never copied into the error — upstream bodies can carry an
86
+ * address or a customer identifier.
87
+ */
79
88
  function toError(status: number, body: string): OrderApiError {
80
- const detail = body.slice(0, 300);
81
- if (status === 400) return new OrderApiError("missing_app_domain", 400, detail);
89
+ if (status === 400) return new OrderApiError("missing_app_domain", 400);
82
90
  if (status === 401) {
83
- // The endpoint distinguishes these in the body; pass it through for logging.
84
- const code: OrderApiErrorCode = /not_provisioned/.test(body)
91
+ const code: OrderApiErrorCode = body.includes("not_provisioned")
85
92
  ? "not_provisioned"
86
- : /unknown_tenant/.test(body)
93
+ : body.includes("unknown_tenant")
87
94
  ? "unknown_tenant"
88
- : /missing_bearer/.test(body)
95
+ : body.includes("missing_bearer")
89
96
  ? "missing_bearer"
90
97
  : "invalid_partner_key";
91
- return new OrderApiError(code, 401, detail);
98
+ return new OrderApiError(code, 401);
92
99
  }
93
- if (status === 404) return new OrderApiError("order_not_found", 404, detail);
94
- if (status >= 500) return new OrderApiError("bad_gateway", 502, detail);
95
- return new OrderApiError("unexpected", status, detail);
100
+ if (status === 404) return new OrderApiError("order_not_found", 404);
101
+ if (status >= 500) return new OrderApiError("bad_gateway", 502);
102
+ return new OrderApiError("unexpected", status);
103
+ }
104
+
105
+ /** Live customization is publication. Every other env is preview. */
106
+ export function commerceStageForCustomization(env: CustomizationEnv): CommerceStage {
107
+ return env === "live" ? "publication" : "preview";
108
+ }
109
+
110
+ /** Owner reads private order fields. An admitted member is a caller. Everyone else is denied. */
111
+ export function commerceActorForAdmin(input: {
112
+ tenantId: string;
113
+ stage: CommerceStage;
114
+ owner: boolean;
115
+ admitted: boolean;
116
+ }): CommerceActor {
117
+ const role: CommerceRole = !input.admitted ? "denied" : input.owner ? "owner" : "caller";
118
+ return { role, tenantId: input.tenantId, stage: input.stage };
119
+ }
120
+
121
+ /** Drop the address unless this actor may read it for this tenant and stage. */
122
+ export function projectCheckoutOrder<T extends { shippingAddress?: unknown }>(
123
+ order: T,
124
+ actor: CommerceActor,
125
+ resource: CommerceScope,
126
+ ): T {
127
+ const read = readOwnerAddress(actor, resource, () => order.shippingAddress ?? null);
128
+ if (read.ok) return order;
129
+ return { ...order, shippingAddress: null };
96
130
  }
97
131
 
98
132
  /** Build the list querystring: appDomain (required) + any provided filters. */
@@ -1,49 +1,163 @@
1
1
  /**
2
- * REAL orders view model — the Orders tab's data source. Fetches the tenant's
3
- * checkout orders via the order client and maps the frozen DTO
4
- * (`@tot/public-runtime` checkout-orders) into the `OrdersViewModel` shape that
5
- * `OrdersWorkspace.astro` renders. Read-only (scope-locks) — no write paths.
6
- *
7
- * The mapping is a pure function (`mapOrdersToViewModel`) for testability; the
8
- * orchestration (`getOrdersViewModel`) lists orders, eagerly fetches each order's
9
- * detail (OrdersWorkspace renders `detailsById[row.id]` for every row), and maps.
10
- * On ANY upstream failure it returns a valid EMPTY view model so the tab renders.
11
- * The live "real qa data" path is gated on ToT-core exposing
12
- * `GET /api/exciseTax/orders`; until that exists this yields the empty model on qa.
13
- *
14
- * Identity/age fields (riskLevel, readiness "identity_and_age", evidence) are
15
- * derived provisionally from commerce facts here; the tot20 verification join
16
- * fills them authoritatively via the DTO's null `verification` /
17
- * `verificationDetail` slots.
2
+ * Orders view model. Maps checkout order DTOs into `OrdersViewModel`.
3
+ * Null `verification` is UNKNOWN and held — not a pass, not collected tax,
4
+ * and not low risk. Payment, tax, verification, risk, stock, and fulfillment
5
+ * stay independent. Read-only.
18
6
  */
19
7
  import type {
20
8
  CheckoutOrderDetail,
9
+ CheckoutOrderFulfillment,
21
10
  CheckoutOrderListFilters,
22
11
  CheckoutOrderPage,
12
+ CheckoutOrderPayment,
23
13
  CheckoutOrderSummary,
24
14
  CheckoutOrderVerification,
15
+ CommerceActor,
16
+ CommerceEvidence,
17
+ CommerceScope,
18
+ CommerceStage,
19
+ ComplianceState,
20
+ EvidenceState,
21
+ FulfillmentState,
22
+ LabelState,
23
+ MinorTotals,
24
+ PaymentState,
25
+ ProviderOutcome,
26
+ PublicCommerceEvent,
27
+ StockState,
28
+ } from "@tot/public-runtime";
29
+ import {
30
+ actorMayCommand,
31
+ decideCommerceCommand,
32
+ evidencePermitsRelease,
33
+ paginateOrders,
34
+ paymentIsCaptured,
35
+ publicCommerceEvent,
36
+ readOwnerAddress,
37
+ sumMinor,
38
+ totalsMatch,
25
39
  } from "@tot/public-runtime";
26
40
  import type {
41
+ OrderAccounting,
27
42
  OrderDetail,
28
43
  OrderEvidenceEvent,
44
+ OrderMoneyScope,
29
45
  OrderQueueSummary,
30
46
  OrderReadinessCheck,
47
+ OrderReadinessColumn,
48
+ OrderRelease,
31
49
  OrderRow,
32
50
  OrderShipState,
33
51
  OrdersViewModel,
34
52
  } from "../viewmodels/orders";
35
53
  import type { CheckoutOrderClient } from "../orderClient";
36
54
 
37
- /** Next-step label, mirroring the mock's queue semantics. */
38
- function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
39
- if (fulfillment === "hold_review_required") {
40
- return blocker === "Payment capture" ? "Capture payment" : "Review risk";
41
- }
42
- if (fulfillment === "completed") return "View record";
43
- return "Create label";
55
+ const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "UNKNOWN", "stale", "unreadable", "absent"];
56
+ const REFUND_PAYMENTS: ReadonlySet<PaymentState> = new Set(["refunded", "refund_requested", "refund_unknown"]);
57
+
58
+ export interface SourcedState<T extends string = string> {
59
+ state: T;
60
+ source: string;
61
+ asOf: string | null;
62
+ }
63
+
64
+ export type AuthorityState = "fresh" | "expired" | "unreadable" | "absent";
65
+
66
+ /** One commerce order. Columns do not borrow each other's state, source, or as-of. */
67
+ export interface ScrappyOrderRecord {
68
+ id: string;
69
+ placedAt: string;
70
+ tenantId: string;
71
+ stage: CommerceStage;
72
+ currency: string;
73
+ payment: SourcedState<PaymentState>;
74
+ tax: SourcedState<EvidenceState>;
75
+ verification: SourcedState<EvidenceState>;
76
+ risk: SourcedState<EvidenceState>;
77
+ stock: SourcedState<StockState>;
78
+ stockEvidence: SourcedState<EvidenceState>;
79
+ fulfillment: SourcedState<FulfillmentState>;
80
+ label: LabelState;
81
+ compliance: ComplianceState;
82
+ providerOutcome: ProviderOutcome | null;
83
+ authority: SourcedState<AuthorityState> | null;
84
+ totals: MinorTotals;
85
+ customerId?: string | null;
86
+ address?: unknown;
44
87
  }
45
88
 
46
- /** Provisional risk until the verification join fills `verification`. Prefer a real riskLevel when present. */
89
+ export interface ScrappyOrderFilter {
90
+ payment?: PaymentState;
91
+ fulfillment?: FulfillmentState;
92
+ from?: string;
93
+ to?: string;
94
+ queue?: "needs_attention" | "ready_to_ship" | "unfulfilled" | "all" | "completed";
95
+ }
96
+
97
+ export interface ScrappyOrderQuery {
98
+ actor: CommerceActor;
99
+ resource: CommerceScope;
100
+ orders: readonly ScrappyOrderRecord[];
101
+ filter?: ScrappyOrderFilter;
102
+ limit: number;
103
+ cursor: string | null;
104
+ }
105
+
106
+ export interface ScrappyOrderView {
107
+ id: string;
108
+ placedAt: string;
109
+ tenantId: string;
110
+ stage: CommerceStage;
111
+ currency: string;
112
+ payment: SourcedState<PaymentState>;
113
+ tax: SourcedState<EvidenceState>;
114
+ verification: SourcedState<EvidenceState>;
115
+ risk: SourcedState<EvidenceState>;
116
+ stock: SourcedState<StockState>;
117
+ stockEvidence: SourcedState<EvidenceState>;
118
+ fulfillment: SourcedState<FulfillmentState>;
119
+ label: LabelState;
120
+ compliance: ComplianceState;
121
+ providerOutcome: ProviderOutcome | null;
122
+ authority: SourcedState<AuthorityState> | null;
123
+ totals: MinorTotals;
124
+ columns: OrderReadinessColumn[];
125
+ evidence: CommerceEvidence;
126
+ release: OrderRelease;
127
+ address?: unknown;
128
+ }
129
+
130
+ export type ScrappyOrderQueryResult =
131
+ | {
132
+ ok: true;
133
+ orders: ScrappyOrderView[];
134
+ nextCursor: string | null;
135
+ invalidCursor: boolean;
136
+ accounting: OrderAccounting;
137
+ }
138
+ | { ok: false; reason: "denied" };
139
+
140
+ export interface CheckoutReadiness {
141
+ columns: OrderReadinessColumn[];
142
+ evidence: CommerceEvidence;
143
+ release: OrderRelease;
144
+ riskLevel: OrderRow["riskLevel"];
145
+ }
146
+
147
+ function evidenceOf(order: ScrappyOrderRecord): CommerceEvidence {
148
+ return {
149
+ verification: order.verification.state,
150
+ tax: order.tax.state,
151
+ risk: order.risk.state,
152
+ stock: order.stockEvidence.state,
153
+ };
154
+ }
155
+
156
+ function isEvidenceState(value: unknown): value is EvidenceState {
157
+ return typeof value === "string" && (EVIDENCE_STATES as readonly string[]).includes(value);
158
+ }
159
+
160
+ /** Display risk. Absent verification is unknown, never a synthetic low. */
47
161
  function deriveRisk(
48
162
  verification: CheckoutOrderVerification | null,
49
163
  blocker: string | null,
@@ -51,85 +165,287 @@ function deriveRisk(
51
165
  const r = verification?.riskLevel;
52
166
  if (r === "low" || r === "medium" || r === "high") return r;
53
167
  if (blocker === "Age verification hold") return "high";
54
- return blocker ? "medium" : "low";
168
+ if (blocker) return "medium";
169
+ return "unknown";
170
+ }
171
+
172
+ function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
173
+ if (fulfillment === "hold_review_required") {
174
+ return blocker === "Payment capture" ? "Capture payment" : "Review risk";
175
+ }
176
+ if (fulfillment === "completed") return "View record";
177
+ return "Create label";
178
+ }
179
+
180
+ function fulfillmentFromCheckout(fulfillment: CheckoutOrderFulfillment): FulfillmentState {
181
+ switch (fulfillment) {
182
+ case "ready_to_ship":
183
+ return "ready";
184
+ case "hold_review_required":
185
+ return "held";
186
+ case "completed":
187
+ return "handed_off";
188
+ case "unfulfilled":
189
+ return "UNKNOWN";
190
+ default: {
191
+ const unreachable: never = fulfillment;
192
+ return unreachable;
193
+ }
194
+ }
195
+ }
196
+
197
+ function readSourcedEvidence(
198
+ verification: CheckoutOrderVerification | null,
199
+ key: "verification" | "tax" | "risk" | "stock",
200
+ ): SourcedState<EvidenceState> {
201
+ if (verification == null) return { state: "UNKNOWN", source: "absent", asOf: null };
202
+ const state = isEvidenceState(verification[key]) ? verification[key] : "UNKNOWN";
203
+ const sourceValue = verification[`${key}Source`];
204
+ const asOfValue = verification[`${key}AsOf`];
205
+ return {
206
+ state,
207
+ source: typeof sourceValue === "string" && sourceValue.length > 0 ? sourceValue : "checkout-order",
208
+ asOf: typeof asOfValue === "string" ? asOfValue : null,
209
+ };
210
+ }
211
+
212
+ export function readinessColumns(order: ScrappyOrderRecord): OrderReadinessColumn[] {
213
+ return [
214
+ { key: "payment", state: order.payment.state, source: order.payment.source, asOf: order.payment.asOf },
215
+ { key: "tax", state: order.tax.state, source: order.tax.source, asOf: order.tax.asOf },
216
+ { key: "verification", state: order.verification.state, source: order.verification.source, asOf: order.verification.asOf },
217
+ { key: "risk", state: order.risk.state, source: order.risk.source, asOf: order.risk.asOf },
218
+ { key: "stock", state: order.stock.state, source: order.stock.source, asOf: order.stock.asOf },
219
+ { key: "fulfillment", state: order.fulfillment.state, source: order.fulfillment.source, asOf: order.fulfillment.asOf },
220
+ ];
221
+ }
222
+
223
+ /** Release blocks are explicit. Capture does not clear a null or stale column. */
224
+ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
225
+ const blocks: string[] = [];
226
+ const evidence = evidenceOf(order);
227
+ if (REFUND_PAYMENTS.has(order.payment.state)) blocks.push("refund");
228
+ if (order.fulfillment.state === "cancelled") blocks.push("cancel");
229
+ if (order.stock.state === "expired" || order.authority?.state === "expired") blocks.push("expired");
230
+ if (order.authority?.state === "unreadable") blocks.push("unreadable-authority");
231
+ if (
232
+ order.verification.state === "unreadable" ||
233
+ order.tax.state === "unreadable" ||
234
+ order.risk.state === "unreadable" ||
235
+ order.stockEvidence.state === "unreadable"
236
+ ) {
237
+ blocks.push("unreadable");
238
+ }
239
+ if (!paymentIsCaptured(order.payment.state)) blocks.push("payment");
240
+ if (!evidencePermitsRelease(evidence)) blocks.push("evidence");
241
+ if (order.stock.state !== "confirmed" && !blocks.includes("stock")) blocks.push("stock");
242
+ if (order.fulfillment.state !== "ready" && !blocks.includes("fulfillment")) blocks.push("fulfillment");
243
+
244
+ const decision = decideCommerceCommand({
245
+ command: "hand-off",
246
+ payment: order.payment.state,
247
+ stock: order.stock.state,
248
+ fulfillment: order.fulfillment.state,
249
+ label: order.label,
250
+ evidence,
251
+ providerOutcome: order.providerOutcome,
252
+ });
253
+ if (!decision.admit) {
254
+ if (decision.reason === "payment" && !blocks.includes("payment") && !blocks.includes("refund")) blocks.push("payment");
255
+ if (decision.reason === "stock" && !blocks.includes("stock") && !blocks.includes("expired")) blocks.push("stock");
256
+ if (decision.reason === "fulfillment" && !blocks.includes("fulfillment") && !blocks.includes("cancel")) {
257
+ blocks.push("fulfillment");
258
+ }
259
+ if (decision.reason === "evidence" && !blocks.includes("evidence")) blocks.push("evidence");
260
+ if (decision.reason === "label") blocks.push("label");
261
+ if (decision.reason === "reconcile") blocks.push("reconcile");
262
+ if (decision.reason === "reuse") blocks.push("reuse");
263
+ }
264
+
265
+ const ready = blocks.length === 0 && decision.admit && paymentIsCaptured(order.payment.state);
266
+ return { state: ready ? "ready" : "held", ready, blocks };
267
+ }
268
+
269
+ function checkoutRecord(order: CheckoutOrderSummary): ScrappyOrderRecord {
270
+ const payment: PaymentState = order.payment;
271
+ return {
272
+ id: order.id,
273
+ placedAt: order.placedAt,
274
+ tenantId: "",
275
+ stage: "preview",
276
+ currency: order.currency,
277
+ payment: { state: payment, source: "checkout-order", asOf: order.placedAt },
278
+ tax: readSourcedEvidence(order.verification, "tax"),
279
+ verification: readSourcedEvidence(order.verification, "verification"),
280
+ risk: readSourcedEvidence(order.verification, "risk"),
281
+ stock: { state: "UNKNOWN", source: "absent", asOf: null },
282
+ stockEvidence: readSourcedEvidence(order.verification, "stock"),
283
+ fulfillment: {
284
+ state: fulfillmentFromCheckout(order.fulfillment),
285
+ source: "checkout-order",
286
+ asOf: order.placedAt,
287
+ },
288
+ label: "UNKNOWN",
289
+ compliance: "UNKNOWN",
290
+ providerOutcome: null,
291
+ authority: null,
292
+ totals: { subtotalMinor: 0, shippingMinor: 0, taxMinor: 0, totalMinor: 0 },
293
+ };
294
+ }
295
+
296
+ /** Checkout DTO assessment. `verification: null` reports UNKNOWN and held. */
297
+ export function assessCheckoutOrder(order: CheckoutOrderSummary): CheckoutReadiness {
298
+ const record = checkoutRecord(order);
299
+ const release = order.verification == null
300
+ ? heldRelease(releaseOf(record))
301
+ : releaseOf(record);
302
+ return {
303
+ columns: readinessColumns(record),
304
+ evidence: evidenceOf(record),
305
+ release,
306
+ riskLevel: deriveRisk(order.verification, order.blocker),
307
+ };
308
+ }
309
+
310
+ function heldRelease(release: OrderRelease): OrderRelease {
311
+ if (release.state === "held") return release;
312
+ return { state: "held", ready: false, blocks: [...release.blocks, "evidence"] };
55
313
  }
56
314
 
57
- /** Map a DTO summary to the view model's OrderRow. */
58
315
  export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
316
+ const verificationMissing = s.verification == null;
317
+ const shipState: OrderShipState = verificationMissing ? "hold_review_required" : s.fulfillment;
318
+ const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : null);
59
319
  return {
60
320
  id: s.id,
61
321
  placedAt: s.placedAt,
62
322
  customerId: s.customer.id ?? "",
63
323
  customerName: s.customer.name,
64
- customerNote: "", // not carried by the commerce DTO
324
+ customerNote: "",
65
325
  itemsSummary: s.itemsSummary,
66
- productId: "", // not carried by the commerce order summary DTO (mock links by product index)
326
+ productId: "",
67
327
  total: s.total,
68
328
  payment: s.payment,
69
- shipState: s.fulfillment,
70
- blocker: s.blocker,
71
- nextStep: deriveNextStep(s.fulfillment, s.blocker),
329
+ shipState,
330
+ blocker,
331
+ nextStep: deriveNextStep(shipState, blocker),
72
332
  riskLevel: deriveRisk(s.verification, s.blocker),
73
333
  };
74
334
  }
75
335
 
76
- /** Commerce-derived readiness checks (the verification join enriches the identity dimension). */
336
+ function paymentValue(payment: CheckoutOrderPayment): string {
337
+ if (payment === "captured") return "Captured";
338
+ if (payment === "refunded") return "Refunded";
339
+ return "Authorized";
340
+ }
341
+
342
+ function columnCheck(
343
+ column: OrderReadinessColumn,
344
+ copy: Omit<OrderReadinessCheck, "key" | "value">,
345
+ ): OrderReadinessCheck {
346
+ return { key: column.key, value: column.state, ...copy };
347
+ }
348
+
77
349
  function deriveReadinessChecks(d: CheckoutOrderDetail): OrderReadinessCheck[] {
350
+ const report = assessCheckoutOrder(d);
351
+ const column = (key: OrderReadinessColumn["key"]) => report.columns.find((item) => item.key === key)!;
352
+ const payment = column("payment");
353
+ const tax = column("tax");
354
+ const verification = column("verification");
78
355
  const ageHold = d.blocker === "Age verification hold";
79
356
  return [
80
357
  {
81
358
  key: "payment_capture",
82
359
  label: "Payment capture",
83
- value: d.payment === "captured" ? "Captured" : "Authorized",
84
- detail: "Capture the authorization before creating the shipping label.",
85
- tone: d.payment === "captured" ? "green" : "amber",
360
+ value: paymentValue(d.payment),
361
+ detail: payment.state === "captured"
362
+ ? "Payment is captured."
363
+ : "Pending or authorized payment is not captured.",
364
+ tone: payment.state === "captured" ? "green" : payment.state === "refunded" ? "red" : "amber",
86
365
  action: "Capture payment",
87
- blocking: d.payment !== "captured",
366
+ blocking: !paymentIsCaptured(payment.state as PaymentState),
88
367
  },
89
368
  {
90
369
  key: "tax_and_fees",
91
370
  label: "Tax and fees",
92
- value: "Collected",
93
- detail: "Required amounts are included in the order total.",
94
- tone: "blue",
371
+ value: tax.state,
372
+ detail: `Tax evidence is ${tax.state}.`,
373
+ tone: tax.state === "fresh" ? "blue" : "amber",
95
374
  action: "View total",
96
- blocking: false,
375
+ blocking: tax.state !== "fresh",
97
376
  },
98
377
  {
99
378
  key: "identity_and_age",
100
379
  label: "Identity and age",
101
- value: ageHold ? "Needs review" : "Passed",
102
- detail: "Checkout assurance completed before order placement.",
103
- tone: ageHold ? "amber" : "green",
380
+ value: ageHold ? "Needs review" : verification.state,
381
+ detail: verification.state === "fresh"
382
+ ? "Verification evidence is fresh."
383
+ : "Verification evidence does not clear this order.",
384
+ tone: verification.state === "fresh" && !ageHold ? "green" : "amber",
104
385
  action: "View evidence",
105
- blocking: ageHold,
386
+ blocking: ageHold || verification.state !== "fresh",
106
387
  },
107
388
  {
108
389
  key: "address",
109
390
  label: "Address",
110
391
  value: d.shippingAddress ? "Serviceable" : "Unknown",
111
- detail: "Destination is deliverable after any hold is resolved.",
392
+ detail: d.shippingAddress
393
+ ? "An address is on the order."
394
+ : "No address is on the order.",
112
395
  tone: d.shippingAddress ? "green" : "amber",
113
396
  action: "View address",
114
397
  blocking: false,
115
398
  },
399
+ columnCheck(column("risk"), {
400
+ label: "Risk",
401
+ detail: `Risk evidence is ${column("risk").state}.`,
402
+ tone: column("risk").state === "fresh" ? "green" : "amber",
403
+ action: "View evidence",
404
+ blocking: column("risk").state !== "fresh",
405
+ }),
406
+ columnCheck(column("stock"), {
407
+ label: "Stock",
408
+ detail: `Stock is ${column("stock").state}.`,
409
+ tone: column("stock").state === "confirmed" ? "green" : "amber",
410
+ action: "View stock",
411
+ blocking: column("stock").state !== "confirmed",
412
+ }),
413
+ columnCheck(column("fulfillment"), {
414
+ label: "Fulfillment",
415
+ detail: `Fulfillment is ${column("fulfillment").state}.`,
416
+ tone: column("fulfillment").state === "ready" ? "green" : "amber",
417
+ action: "View fulfillment",
418
+ blocking: column("fulfillment").state !== "ready" && column("fulfillment").state !== "handed_off",
419
+ }),
116
420
  ];
117
421
  }
118
422
 
119
- /** Base commerce evidence events (the verification join replaces with the real verification timeline). */
120
423
  function deriveEvidenceTimeline(d: CheckoutOrderDetail): OrderEvidenceEvent[] {
121
424
  const events: OrderEvidenceEvent[] = [
122
425
  { title: "Order imported", detail: "Storefront order became available in the admin queue.", at: d.placedAt },
123
- { title: "Payment authorized", detail: "The customer completed checkout and payment was authorized.", at: d.placedAt },
124
426
  ];
427
+ if (d.payment === "authorized") {
428
+ events.push({ title: "Payment authorized", detail: "Payment is authorized and not captured.", at: d.placedAt });
429
+ } else if (d.payment === "captured") {
430
+ events.push({ title: "Payment captured", detail: "Payment is captured.", at: d.placedAt });
431
+ } else {
432
+ events.push({ title: "Payment refunded", detail: "A refund blocks release.", at: d.placedAt });
433
+ }
434
+ if (d.verification == null) {
435
+ events.push({
436
+ title: "Verification unknown",
437
+ detail: "Verification evidence is absent. Release stays held.",
438
+ at: d.placedAt,
439
+ });
440
+ }
125
441
  if (d.blocker) {
126
442
  events.push({ title: "Hold added", detail: `${d.blocker} must be resolved before shipment.`, at: d.placedAt });
127
443
  }
128
444
  return events;
129
445
  }
130
446
 
131
- /** Map a DTO detail (its summary already mapped to `row`) to the view model's OrderDetail. */
132
447
  export function mapDetailToDetail(d: CheckoutOrderDetail, row: OrderRow): OrderDetail {
448
+ const report = assessCheckoutOrder(d);
133
449
  return {
134
450
  ...row,
135
451
  lineItems: d.lineItems.map((li) => ({
@@ -143,6 +459,8 @@ export function mapDetailToDetail(d: CheckoutOrderDetail, row: OrderRow): OrderD
143
459
  taxesAndFees: d.totals.taxesAndFees,
144
460
  readinessChecks: deriveReadinessChecks(d),
145
461
  evidenceTimeline: deriveEvidenceTimeline(d),
462
+ columns: report.columns,
463
+ release: report.release,
146
464
  };
147
465
  }
148
466
 
@@ -159,18 +477,12 @@ function buildQueues(rows: OrderRow[]): OrderQueueSummary[] {
159
477
  ];
160
478
  }
161
479
 
162
- /**
163
- * PURE map of DTO orders → the OrdersViewModel. `details` supplies the per-order
164
- * detail (line items / totals); a row with no matching detail falls back to a
165
- * minimal detail built from the row so `detailsById[row.id]` is always present.
166
- */
167
480
  export function mapOrdersToViewModel(
168
481
  targetTenant: string,
169
482
  summaries: CheckoutOrderSummary[],
170
483
  details: Record<string, CheckoutOrderDetail>,
171
484
  ): OrdersViewModel {
172
485
  const rows = summaries.map(mapSummaryToRow);
173
-
174
486
  const detailsById: Record<string, OrderDetail> = {};
175
487
  for (const row of rows) {
176
488
  const d = details[row.id];
@@ -188,7 +500,9 @@ export function mapOrdersToViewModel(
188
500
  }
189
501
 
190
502
  const paidOrders = rows.filter((r) => r.payment === "captured");
503
+ const refunded = rows.filter((r) => r.payment === "refunded");
191
504
  const grossSales = paidOrders.reduce((sum, r) => sum + r.total, 0);
505
+ const refunds = refunded.reduce((sum, r) => sum + r.total, 0);
192
506
 
193
507
  return {
194
508
  targetTenant,
@@ -199,13 +513,12 @@ export function mapOrdersToViewModel(
199
513
  grossSales,
200
514
  paidOrders: paidOrders.length,
201
515
  averageOrder: paidOrders.length ? Math.round((grossSales / paidOrders.length) * 100) / 100 : 0,
202
- refunds: 0,
516
+ refunds,
203
517
  oldestOpenAt: rows.length ? rows[rows.length - 1]!.placedAt : null,
204
518
  },
205
519
  };
206
520
  }
207
521
 
208
- /** A valid, empty view model — rendered when nothing is available or a fetch fails. */
209
522
  export function emptyOrdersViewModel(targetTenant: string): OrdersViewModel {
210
523
  return mapOrdersToViewModel(targetTenant, [], {});
211
524
  }
@@ -213,22 +526,9 @@ export function emptyOrdersViewModel(targetTenant: string): OrdersViewModel {
213
526
  export interface GetOrdersViewModelOptions {
214
527
  targetTenant: string;
215
528
  client: CheckoutOrderClient;
216
- /** List filters (queue/status/pagination); passed through to the order client. */
217
529
  filters?: Parameters<CheckoutOrderClient["listOrders"]>[0];
218
530
  }
219
531
 
220
- /**
221
- * Fetch + map the real orders view model. Lists orders, eagerly fetches each
222
- * order's detail (bounded by the list page size), and maps. Any upstream error
223
- * (incl. the not-yet-available ToT-core endpoint) degrades to the empty model so
224
- * the Orders tab always renders.
225
- */
226
- /**
227
- * Load outcome for the Orders tab — lets the UI pick the right state:
228
- * `loaded` (rows), `empty` (fetch OK, 0 orders), `error` (upstream failed), and
229
- * `not_configured` (produced by the caller when no bearer/client is available —
230
- * the service itself never returns it). `page` carries the pagination cursor.
231
- */
232
532
  export type OrdersLoadStatus = "loaded" | "empty" | "error" | "not_configured";
233
533
 
234
534
  export interface OrdersLoadResult {
@@ -237,12 +537,6 @@ export interface OrdersLoadResult {
237
537
  page: CheckoutOrderPage | null;
238
538
  }
239
539
 
240
- /**
241
- * Parse server-backed list filters from a request's query string. `queue`
242
- * of `"all"` (or absent) means no server filter. Non-numeric `limit` is ignored.
243
- * `payment`/search are NOT here — they are client-side refinements (the DTO list
244
- * API has no payment filter, per contract §9).
245
- */
246
540
  export function parseOrderFilters(params: URLSearchParams): CheckoutOrderListFilters {
247
541
  const f: CheckoutOrderListFilters = {};
248
542
  const queue = params.get("queue");
@@ -260,14 +554,7 @@ export function parseOrderFilters(params: URLSearchParams): CheckoutOrderListFil
260
554
  return f;
261
555
  }
262
556
 
263
- /**
264
- * Fetch + map the orders view model AND report the load outcome. Lists,
265
- * eagerly fetches per-order detail, maps. Distinguishes empty from error so the
266
- * tab can degrade gracefully. Never throws.
267
- */
268
- export async function getOrdersViewModelResult(
269
- opts: GetOrdersViewModelOptions,
270
- ): Promise<OrdersLoadResult> {
557
+ export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
271
558
  try {
272
559
  const { orders, page } = await opts.client.listOrders(opts.filters);
273
560
  const details: Record<string, CheckoutOrderDetail> = {};
@@ -290,9 +577,159 @@ export async function getOrdersViewModelResult(
290
577
  }
291
578
  }
292
579
 
293
- /** Convenience wrapper: just the view model (see getOrdersViewModelResult). */
294
- export async function getOrdersViewModel(
295
- opts: GetOrdersViewModelOptions,
296
- ): Promise<OrdersViewModel> {
580
+ export async function getOrdersViewModel(opts: GetOrdersViewModelOptions): Promise<OrdersViewModel> {
297
581
  return (await getOrdersViewModelResult(opts)).vm;
298
582
  }
583
+
584
+ function moneyScope(orders: readonly ScrappyOrderRecord[]): OrderMoneyScope {
585
+ const values: number[] = [];
586
+ const refunds: number[] = [];
587
+ for (const order of orders) {
588
+ if (!totalsMatch(order.totals)) throw new Error("totals do not match minor units");
589
+ if (REFUND_PAYMENTS.has(order.payment.state)) refunds.push(order.totals.totalMinor);
590
+ else if (paymentIsCaptured(order.payment.state) || order.payment.state === "pending" || order.payment.state === "authorized") {
591
+ values.push(order.totals.totalMinor);
592
+ }
593
+ }
594
+ return {
595
+ count: orders.length,
596
+ valueMinor: sumMinor(values),
597
+ refundMinor: sumMinor(refunds),
598
+ };
599
+ }
600
+
601
+ function matchesFilter(order: ScrappyOrderRecord, filter: ScrappyOrderFilter | undefined): boolean {
602
+ if (!filter) return true;
603
+ if (filter.payment && order.payment.state !== filter.payment) return false;
604
+ if (filter.fulfillment && order.fulfillment.state !== filter.fulfillment) return false;
605
+ if (filter.from && order.placedAt < filter.from) return false;
606
+ if (filter.to && order.placedAt >= filter.to) return false;
607
+ if (filter.queue && filter.queue !== "all") {
608
+ const release = releaseOf(order);
609
+ if (filter.queue === "needs_attention" && release.state !== "held") return false;
610
+ if (filter.queue === "ready_to_ship" && !release.ready) return false;
611
+ if (filter.queue === "completed" && order.fulfillment.state !== "handed_off") return false;
612
+ if (filter.queue === "unfulfilled" && (order.fulfillment.state === "handed_off" || order.fulfillment.state === "cancelled")) {
613
+ return false;
614
+ }
615
+ }
616
+ return true;
617
+ }
618
+
619
+ function toView(order: ScrappyOrderRecord, address: unknown | undefined): ScrappyOrderView {
620
+ const view: ScrappyOrderView = {
621
+ id: order.id,
622
+ placedAt: order.placedAt,
623
+ tenantId: order.tenantId,
624
+ stage: order.stage,
625
+ currency: order.currency,
626
+ payment: order.payment,
627
+ tax: order.tax,
628
+ verification: order.verification,
629
+ risk: order.risk,
630
+ stock: order.stock,
631
+ stockEvidence: order.stockEvidence,
632
+ fulfillment: order.fulfillment,
633
+ label: order.label,
634
+ compliance: order.compliance,
635
+ providerOutcome: order.providerOutcome,
636
+ authority: order.authority,
637
+ totals: order.totals,
638
+ columns: readinessColumns(order),
639
+ evidence: evidenceOf(order),
640
+ release: releaseOf(order),
641
+ };
642
+ if (address !== undefined) view.address = address;
643
+ return view;
644
+ }
645
+
646
+ /**
647
+ * Cursor page over one tenant and stage. Storewide, filter, and page money are
648
+ * computed separately. A caller sees no address. A mismatched tenant or role
649
+ * sees nothing.
650
+ */
651
+ export function queryScrappyOrders(input: ScrappyOrderQuery): ScrappyOrderQueryResult {
652
+ if (!actorMayCommand(input.actor, input.resource)) return { ok: false, reason: "denied" };
653
+ const storewide = input.orders.filter(
654
+ (order) => order.tenantId === input.resource.tenantId && order.stage === input.resource.stage,
655
+ );
656
+ const filtered = storewide.filter((order) => matchesFilter(order, input.filter));
657
+ const page = paginateOrders(filtered, input.limit, input.cursor);
658
+ return {
659
+ ok: true,
660
+ orders: page.orders.map((order) => {
661
+ const read = readOwnerAddress(input.actor, input.resource, () => order.address);
662
+ return toView(order, read.ok ? read.address ?? undefined : undefined);
663
+ }),
664
+ nextCursor: page.nextCursor,
665
+ invalidCursor: page.invalidCursor,
666
+ accounting: {
667
+ storewide: moneyScope(storewide),
668
+ filter: moneyScope(filtered),
669
+ page: moneyScope(page.orders),
670
+ },
671
+ };
672
+ }
673
+
674
+ /** Generic event. Customer and address fields on the input are not copied. */
675
+ export function observeScrappyOrder(order: ScrappyOrderRecord): PublicCommerceEvent {
676
+ return publicCommerceEvent({
677
+ eventVersion: 1,
678
+ orderId: order.id,
679
+ placedAt: order.placedAt,
680
+ currency: order.currency,
681
+ payment: order.payment.state,
682
+ compliance: order.compliance,
683
+ stock: order.stock.state,
684
+ fulfillment: order.fulfillment.state,
685
+ label: order.label,
686
+ totals: order.totals,
687
+ verificationDetail: evidenceOf(order),
688
+ customerId: order.customerId,
689
+ address: order.address,
690
+ });
691
+ }
692
+
693
+ interface FixtureOrder {
694
+ id: string;
695
+ placedAt: string;
696
+ currency: string;
697
+ payment: PaymentState;
698
+ compliance: ComplianceState;
699
+ stock: StockState;
700
+ fulfillment: FulfillmentState;
701
+ label: LabelState;
702
+ totals: MinorTotals;
703
+ customer?: { id?: string | null };
704
+ verificationDetail: CommerceEvidence;
705
+ }
706
+
707
+ /** Adapt a frozen fixture order. Evidence states are copied, not recomputed. */
708
+ export function scrappyRecordFromFixture(
709
+ order: FixtureOrder,
710
+ tenantId: string,
711
+ stage: CommerceStage,
712
+ ): ScrappyOrderRecord {
713
+ const at = order.placedAt;
714
+ const source = "scrappy-orders-v1";
715
+ return {
716
+ id: order.id,
717
+ placedAt: at,
718
+ tenantId,
719
+ stage,
720
+ currency: order.currency,
721
+ payment: { state: order.payment, source, asOf: at },
722
+ tax: { state: order.verificationDetail.tax, source, asOf: at },
723
+ verification: { state: order.verificationDetail.verification, source, asOf: at },
724
+ risk: { state: order.verificationDetail.risk, source, asOf: at },
725
+ stock: { state: order.stock, source, asOf: at },
726
+ stockEvidence: { state: order.verificationDetail.stock, source, asOf: at },
727
+ fulfillment: { state: order.fulfillment, source, asOf: at },
728
+ label: order.label,
729
+ compliance: order.compliance,
730
+ providerOutcome: null,
731
+ authority: null,
732
+ totals: order.totals,
733
+ customerId: order.customer?.id ?? null,
734
+ };
735
+ }
@@ -8,6 +8,9 @@
8
8
 
9
9
  export type OrderPaymentState = "authorized" | "captured" | "refunded";
10
10
 
11
+ /** Display risk. `unknown` is absent evidence, not a low-risk clearance. */
12
+ export type OrderRiskLevel = "low" | "medium" | "high" | "unknown";
13
+
11
14
  export type OrderShipState = "ready_to_ship" | "hold_review_required" | "unfulfilled" | "completed";
12
15
 
13
16
  /** The exact merchant-facing language the shell contract requires. */
@@ -54,7 +57,35 @@ export interface OrderRow {
54
57
  shipState: OrderShipState;
55
58
  blocker: string | null;
56
59
  nextStep: string;
57
- riskLevel: "low" | "medium" | "high";
60
+ riskLevel: OrderRiskLevel;
61
+ }
62
+
63
+ /** One independent readiness fact. Source and as-of belong to this column only. */
64
+ export interface OrderReadinessColumn {
65
+ key: "payment" | "tax" | "verification" | "risk" | "stock" | "fulfillment";
66
+ state: string;
67
+ source: string;
68
+ asOf: string | null;
69
+ }
70
+
71
+ /** Release is held unless every block is empty. `held` is not inferred from payment capture. */
72
+ export interface OrderRelease {
73
+ state: "held" | "ready";
74
+ ready: boolean;
75
+ blocks: readonly string[];
76
+ }
77
+
78
+ /** Storewide, filtered, and page money are separate scopes. Refunds are not values. */
79
+ export interface OrderMoneyScope {
80
+ count: number;
81
+ valueMinor: number;
82
+ refundMinor: number;
83
+ }
84
+
85
+ export interface OrderAccounting {
86
+ storewide: OrderMoneyScope;
87
+ filter: OrderMoneyScope;
88
+ page: OrderMoneyScope;
58
89
  }
59
90
 
60
91
  export interface OrderDetail extends OrderRow {
@@ -64,6 +95,9 @@ export interface OrderDetail extends OrderRow {
64
95
  shipping: number;
65
96
  estimatedTax: number;
66
97
  taxesAndFees: number;
98
+ /** Present when the order was assessed. Absent on the mock book. */
99
+ columns?: OrderReadinessColumn[];
100
+ release?: OrderRelease;
67
101
  }
68
102
 
69
103
  export type OrderQueueKey = "needs_attention" | "ready_to_ship" | "unfulfilled" | "all" | "completed";
@@ -89,4 +123,6 @@ export interface OrdersViewModel {
89
123
  rows: OrderRow[];
90
124
  detailsById: Record<string, OrderDetail>;
91
125
  today: OrdersTodaySummary;
126
+ /** Set by a scrappy book query. Never copied from the current page alone. */
127
+ accounting?: OrderAccounting;
92
128
  }
@@ -2,18 +2,22 @@
2
2
  * `GET /api/admin/orders/:id` — one order's full detail for the Orders tab.
3
3
  *
4
4
  * Server-side proxy to checkout `GET /commerce/orders/:orderId` for the
5
- * resolved tenant + env. Bearer stays server-side; response is the CheckoutOrderDetail
6
- * DTO. `verificationDetail` is currently empty — consumers tolerate that.
7
- * Read-only (scope-locks).
5
+ * resolved tenant + env. Bearer stays server-side. Address is included only
6
+ * for an owner of this tenant and stage. Readiness is assessed, not inferred.
7
+ * Read-only.
8
8
  */
9
9
  import type { APIContext } from "astro";
10
10
  import { readEnv } from "@/lib/env";
11
11
  import { requireAdminApiEntry } from "@/lib/auth/adminApiGuard";
12
12
  import {
13
+ commerceActorForAdmin,
14
+ commerceStageForCustomization,
13
15
  createCheckoutOrderClient,
14
16
  orderApiBaseUrl,
15
17
  OrderApiError,
18
+ projectCheckoutOrder,
16
19
  } from "@/lib/admin/orderClient";
20
+ import { assessCheckoutOrder } from "@/lib/admin/services/orders";
17
21
 
18
22
  export const prerender = false;
19
23
 
@@ -49,8 +53,19 @@ export async function GET(context: APIContext): Promise<Response> {
49
53
  });
50
54
 
51
55
  try {
52
- const order = await client.getOrder(orderId);
53
- return new Response(JSON.stringify({ order }), { headers: JSON_HEADERS });
56
+ const stage = commerceStageForCustomization(customizationEnv);
57
+ const actor = commerceActorForAdmin({
58
+ tenantId: tenant.appDomain,
59
+ stage,
60
+ owner: access.entry.isOwnerViewer,
61
+ admitted: access.entry.admitted,
62
+ });
63
+ const order = projectCheckoutOrder(await client.getOrder(orderId), actor, {
64
+ tenantId: tenant.appDomain,
65
+ stage,
66
+ });
67
+ const readiness = assessCheckoutOrder(order);
68
+ return new Response(JSON.stringify({ order, readiness }), { headers: JSON_HEADERS });
54
69
  } catch (err) {
55
70
  if (err instanceof OrderApiError) return jsonError(err.code, err.status);
56
71
  return jsonError("unexpected", 500);
@@ -10,7 +10,6 @@
10
10
  * host/approver layer) — same posture as `api/evidence.json.ts`; no new gate here.
11
11
  */
12
12
  import type { APIContext } from "astro";
13
- import type { CheckoutOrderListFilters } from "@tot/public-runtime";
14
13
  import { readEnv } from "@/lib/env";
15
14
  import { requireAdminApiEntry } from "@/lib/auth/adminApiGuard";
16
15
  import {
@@ -18,6 +17,7 @@ import {
18
17
  orderApiBaseUrl,
19
18
  OrderApiError,
20
19
  } from "@/lib/admin/orderClient";
20
+ import { assessCheckoutOrder, parseOrderFilters } from "@/lib/admin/services/orders";
21
21
 
22
22
  export const prerender = false;
23
23
 
@@ -50,16 +50,8 @@ export async function GET(context: APIContext): Promise<Response> {
50
50
  }
51
51
 
52
52
  const url = new URL(context.request.url);
53
- const q = url.searchParams;
54
- const limitRaw = q.get("limit");
55
- const filters: CheckoutOrderListFilters = {
56
- queue: q.get("queue") ?? undefined,
57
- status: q.get("status") ?? undefined,
58
- from: q.get("from") ?? undefined,
59
- to: q.get("to") ?? undefined,
60
- limit: limitRaw != null ? Number(limitRaw) : undefined,
61
- cursor: q.get("cursor") ?? undefined,
62
- };
53
+ // Tenant comes from the host, never from a query parameter.
54
+ const filters = parseOrderFilters(url.searchParams);
63
55
 
64
56
  const client = createCheckoutOrderClient({
65
57
  baseUrl: orderApiBaseUrl(customizationEnv),
@@ -69,7 +61,11 @@ export async function GET(context: APIContext): Promise<Response> {
69
61
 
70
62
  try {
71
63
  const data = await client.listOrders(filters);
72
- return new Response(JSON.stringify(data), { headers: JSON_HEADERS });
64
+ const orders = data.orders.map((order) => ({
65
+ ...order,
66
+ readiness: assessCheckoutOrder(order),
67
+ }));
68
+ return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
73
69
  } catch (err) {
74
70
  if (err instanceof OrderApiError) return jsonError(err.code, err.status);
75
71
  return jsonError("unexpected", 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.51",
3
+ "version": "2.4.52",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",