@tokenoftrust/storefront-runner 2.4.50 → 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.
- package/apps/storefront/src/lib/admin/orderClient.ts +45 -11
- package/apps/storefront/src/lib/admin/services/orders.ts +524 -87
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +37 -1
- package/apps/storefront/src/lib/commerce/finalCartAuthorization.ts +234 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +749 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +20 -5
- package/apps/storefront/src/pages/api/admin/orders.ts +8 -12
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +366 -0
- package/package.json +1 -1
|
@@ -1,49 +1,163 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "",
|
|
324
|
+
customerNote: "",
|
|
65
325
|
itemsSummary: s.itemsSummary,
|
|
66
|
-
productId: "",
|
|
326
|
+
productId: "",
|
|
67
327
|
total: s.total,
|
|
68
328
|
payment: s.payment,
|
|
69
|
-
shipState
|
|
70
|
-
blocker
|
|
71
|
-
nextStep: deriveNextStep(
|
|
329
|
+
shipState,
|
|
330
|
+
blocker,
|
|
331
|
+
nextStep: deriveNextStep(shipState, blocker),
|
|
72
332
|
riskLevel: deriveRisk(s.verification, s.blocker),
|
|
73
333
|
};
|
|
74
334
|
}
|
|
75
335
|
|
|
76
|
-
|
|
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
|
|
84
|
-
detail:
|
|
85
|
-
|
|
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:
|
|
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:
|
|
93
|
-
detail:
|
|
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:
|
|
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" :
|
|
102
|
-
detail:
|
|
103
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|