@tokenoftrust/storefront-runner 6.0.2 → 6.1.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/src/lib/fulfillment/admin/viewmodels/fulfillment.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +11 -2
- package/apps/storefront/src/lib/fulfillment/readiness.ts +12 -1
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +21 -8
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +30 -9
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +91 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +16 -1
|
@@ -650,6 +650,7 @@ function applyToOrder(state: WorkspaceState, order: WorkspaceOrder, command: Wor
|
|
|
650
650
|
if (!state.carrierAccountConfigured) return refuse("carrier-account");
|
|
651
651
|
const decision = decideCommerceCommand({
|
|
652
652
|
command: "purchase-label",
|
|
653
|
+
shipTo: order.ownerDestination ? "present" : "absent",
|
|
653
654
|
payment: order.payment,
|
|
654
655
|
stock: order.stock,
|
|
655
656
|
fulfillment: order.fulfillment,
|
|
@@ -729,6 +730,7 @@ function applyToOrder(state: WorkspaceState, order: WorkspaceOrder, command: Wor
|
|
|
729
730
|
if (order.adultSignature !== "accepted") return refuse("adult-signature");
|
|
730
731
|
const decision = decideCommerceCommand({
|
|
731
732
|
command: "hand-off",
|
|
733
|
+
shipTo: order.ownerDestination ? "present" : "absent",
|
|
732
734
|
payment: order.payment,
|
|
733
735
|
stock: order.stock,
|
|
734
736
|
fulfillment: order.fulfillment,
|
|
@@ -29,9 +29,10 @@ import {
|
|
|
29
29
|
type PaymentState,
|
|
30
30
|
type ProviderOutcome,
|
|
31
31
|
type StockState,
|
|
32
|
+
type OrderContactState,
|
|
32
33
|
} from "@tot/public-runtime";
|
|
33
34
|
import { readD1 } from "@/lib/publication/shared/env";
|
|
34
|
-
import { terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
|
|
35
|
+
import { readOrderContactAvailability, terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
|
|
35
36
|
import { decideAdminApiAccess } from "@/lib/access/auth/adminApiGuard";
|
|
36
37
|
import { readViewerSession } from "@/lib/access/auth/route";
|
|
37
38
|
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
@@ -149,6 +150,7 @@ export type FulfillmentResult<T> =
|
|
|
149
150
|
| { ok: false; code: string; holds?: readonly ReleaseHold[] };
|
|
150
151
|
|
|
151
152
|
interface LoadedOrder {
|
|
153
|
+
shipTo: OrderContactState;
|
|
152
154
|
sourceAuthority: AuthorityRow|null;
|
|
153
155
|
sourceHolds: SourceReadiness[];
|
|
154
156
|
order: {
|
|
@@ -947,6 +949,7 @@ export class FulfillmentService {
|
|
|
947
949
|
|
|
948
950
|
private readiness(state: LoadedOrder): ReadinessInput {
|
|
949
951
|
return {
|
|
952
|
+
shipTo: state.shipTo,
|
|
950
953
|
sourceHolds: state.sourceHolds,
|
|
951
954
|
payment: state.order.payment,
|
|
952
955
|
stock: state.order.stock,
|
|
@@ -1387,7 +1390,11 @@ export class FulfillmentService {
|
|
|
1387
1390
|
try { sourceAuthority=await this.db.get<AuthorityRow & Record<string,unknown>>(`SELECT * FROM commerce_source_authority WHERE tenant_id=? AND stage=? AND source=? AND source_order_id=?`,[tenantId,stage,order.source,order.sourceOrderId]) ?? null;
|
|
1388
1391
|
sourceHolds=sourceAuthority?.order_id!==order.orderId?["source-authority-unavailable"]:authorityHolds(sourceAuthority,this.now());
|
|
1389
1392
|
}catch{sourceHolds=["source-authority-unreadable"];}
|
|
1393
|
+
const contacts = await readOrderContactAvailability({
|
|
1394
|
+
all: (sql, ...params) => this.db.all(sql, params),
|
|
1395
|
+
}, { tenantId, stage, orderIds: [orderId], now: new Date(this.now()) });
|
|
1390
1396
|
const loaded: LoadedOrder = {
|
|
1397
|
+
shipTo: contacts.get(orderId)?.shipTo ?? "unreadable",
|
|
1391
1398
|
sourceAuthority,sourceHolds,
|
|
1392
1399
|
order,
|
|
1393
1400
|
evidence,
|
|
@@ -1428,7 +1435,7 @@ export class FulfillmentService {
|
|
|
1428
1435
|
const readiness = this.readiness(loaded);
|
|
1429
1436
|
const holds = releaseHolds(readiness);
|
|
1430
1437
|
loaded.view.holds = holds;
|
|
1431
|
-
if(
|
|
1438
|
+
if(holds.length&&order.fulfillment==="ready")loaded.view.fulfillment="held";
|
|
1432
1439
|
loaded.view.canRelease = canRelease(readiness) && !loaded.shipment;
|
|
1433
1440
|
loaded.view.canHandOff = handoffRefusal(readiness) === null && loaded.shipment?.handoffState !== "handed_off";
|
|
1434
1441
|
loaded.view.shipment = loaded.shipment
|
|
@@ -1558,6 +1565,8 @@ function projectedFromSnapshot(
|
|
|
1558
1565
|
if (snapshot.fulfillment === "cancelled" || payment === "voided" || payment === "refunded") return "cancelled";
|
|
1559
1566
|
if (snapshot.fulfillment === "UNKNOWN") return "UNKNOWN";
|
|
1560
1567
|
const holds = releaseHolds({
|
|
1568
|
+
// Source disposition is independent of contact delivery; commands re-read the vault.
|
|
1569
|
+
shipTo: "present",
|
|
1561
1570
|
payment,
|
|
1562
1571
|
stock: snapshot.stock,
|
|
1563
1572
|
fulfillment: "ready",
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type PaymentState,
|
|
17
17
|
type ProviderOutcome,
|
|
18
18
|
type StockState,
|
|
19
|
+
type OrderContactState,
|
|
19
20
|
} from "@tot/public-runtime";
|
|
20
21
|
import type { SourceReadiness } from "../orders/data/sourceAuthorityStore.js";
|
|
21
22
|
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
@@ -42,6 +43,10 @@ export const RELEASE_HOLDS = [
|
|
|
42
43
|
"stock-unknown",
|
|
43
44
|
"stock-unconfirmed",
|
|
44
45
|
"fulfillment-unknown",
|
|
46
|
+
"ship-to-absent",
|
|
47
|
+
"ship-to-expired",
|
|
48
|
+
"ship-to-erased",
|
|
49
|
+
"ship-to-unreadable",
|
|
45
50
|
] as const;
|
|
46
51
|
|
|
47
52
|
export type ReleaseHold = (typeof RELEASE_HOLDS)[number];
|
|
@@ -54,6 +59,7 @@ export interface CarrierPolicy {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export interface ReadinessInput {
|
|
62
|
+
shipTo?: OrderContactState;
|
|
57
63
|
sourceHolds?: readonly SourceReadiness[];
|
|
58
64
|
payment: PaymentState;
|
|
59
65
|
stock: StockState;
|
|
@@ -100,6 +106,10 @@ export function releaseHolds(input: ReadinessInput): ReleaseHold[] {
|
|
|
100
106
|
else if (!paymentIsCaptured(input.payment)) add(holds, "payment-not-captured");
|
|
101
107
|
if (input.stock === "UNKNOWN") add(holds, "stock-unknown");
|
|
102
108
|
else if (input.stock !== "confirmed") add(holds, "stock-unconfirmed");
|
|
109
|
+
if (input.shipTo !== "present" && input.shipTo !== "restricted") {
|
|
110
|
+
add(holds, input.shipTo === "absent" ? "ship-to-absent" : input.shipTo === "expired" ? "ship-to-expired" :
|
|
111
|
+
input.shipTo === "erased" ? "ship-to-erased" : "ship-to-unreadable");
|
|
112
|
+
}
|
|
103
113
|
return holds;
|
|
104
114
|
}
|
|
105
115
|
|
|
@@ -107,7 +117,7 @@ export function canRelease(input: ReadinessInput): boolean {
|
|
|
107
117
|
return input.fulfillment === "ready" && releaseHolds(input).length === 0;
|
|
108
118
|
}
|
|
109
119
|
|
|
110
|
-
export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-signature" | "label" | "reconcile" | "fulfillment" | "evidence" | "payment" | "stock" | "reuse" | null {
|
|
120
|
+
export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-signature" | "label" | "reconcile" | "fulfillment" | "evidence" | "payment" | "stock" | "reuse" | "ship-to" | null {
|
|
111
121
|
const holds = releaseHolds(input);
|
|
112
122
|
if (holds.length > 0) return holds[0] ?? "fulfillment";
|
|
113
123
|
const decision = decideCommerceCommand({
|
|
@@ -118,6 +128,7 @@ export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-sign
|
|
|
118
128
|
label: input.label,
|
|
119
129
|
evidence: input.evidence,
|
|
120
130
|
providerOutcome: input.providerOutcome,
|
|
131
|
+
shipTo: input.shipTo,
|
|
121
132
|
});
|
|
122
133
|
if (!decision.admit) return decision.reason === "admitted" ? "fulfillment" : decision.reason;
|
|
123
134
|
if (input.adultSignatureRequired && input.adultSignature !== "accepted") return "adult-signature";
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* the only per-tenant record for that tenant. A tenant on its own FoxyCart store reads that
|
|
10
10
|
* store's partner API with `FOXY_ORDER_API_KEY`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Contact values stay in the encrypted vault. Order reads carry only its presence metadata;
|
|
13
|
+
* authorized contact routes retrieve the private values separately.
|
|
14
14
|
*/
|
|
15
15
|
import type {
|
|
16
16
|
CheckoutOrderDetail,
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
CommerceStage,
|
|
25
25
|
CustomizationEnv,
|
|
26
26
|
PaymentState,
|
|
27
|
+
OrderContactAvailability,
|
|
27
28
|
} from "@tot/public-runtime";
|
|
28
29
|
import { evidencePermitsRelease, paginateOrders } from "@tot/public-runtime";
|
|
29
30
|
import { commerceStageForEnv } from "@/lib/fulfillment/fulfillmentService";
|
|
@@ -31,6 +32,7 @@ import { readD1, readEnv } from "@/lib/publication/shared/env";
|
|
|
31
32
|
import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
|
|
32
33
|
import { ScrappyOrderLedger, type CommerceOrderBookEntry } from "../data/ordersStore.js";
|
|
33
34
|
import { foldCommerceEvents, type CommerceSourceEvent } from "../data/orderMapper.js";
|
|
35
|
+
import { readOrderContactAvailability } from "../vault/orderContactVault.js";
|
|
34
36
|
import {
|
|
35
37
|
OrderApiError,
|
|
36
38
|
createCheckoutOrderClient,
|
|
@@ -115,7 +117,9 @@ export async function resolveOrderSource(
|
|
|
115
117
|
if (checkout === "shared-test-store") {
|
|
116
118
|
const db = await deps.readLedgerDb();
|
|
117
119
|
if (!db) return { kind: "not-connected", checkout, gap: "ledger-unavailable" };
|
|
118
|
-
const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage }
|
|
120
|
+
const book = ledgerOrderBook(new ScrappyOrderLedger(db), { tenantId, stage }, {
|
|
121
|
+
readContactAvailability: (orderIds) => readOrderContactAvailability(db, { tenantId, stage, orderIds, now: new Date() }),
|
|
122
|
+
});
|
|
119
123
|
return { kind: "ledger", checkout, scope: { tenantId, stage }, client: book.client, status: book.status };
|
|
120
124
|
}
|
|
121
125
|
|
|
@@ -212,7 +216,7 @@ interface LedgerOrder {
|
|
|
212
216
|
detail: CheckoutOrderDetail;
|
|
213
217
|
}
|
|
214
218
|
|
|
215
|
-
function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
219
|
+
function ledgerOrder(entry: CommerceOrderBookEntry, contactAvailability: OrderContactAvailability): LedgerOrder {
|
|
216
220
|
const placedAt = isoInstant(entry.placedAt);
|
|
217
221
|
const snapshot = entry.events.length > 0 ? foldCommerceEvents(entry.events) : null;
|
|
218
222
|
// The order row is the state of record: provider events and fulfillment commands both write it.
|
|
@@ -231,10 +235,12 @@ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
|
231
235
|
state = "completed";
|
|
232
236
|
blocker = "Cancelled";
|
|
233
237
|
} else if (fulfillment === "UNKNOWN") state = "unfulfilled";
|
|
234
|
-
else if (fulfillment === "ready" && releasable
|
|
238
|
+
else if (fulfillment === "ready" && releasable &&
|
|
239
|
+
(contactAvailability.shipTo === "present" || contactAvailability.shipTo === "restricted")) state = "ready_to_ship";
|
|
235
240
|
else {
|
|
236
241
|
state = "hold_review_required";
|
|
237
|
-
blocker = payment !== "captured" ? "Payment capture" : evidencePermitsRelease(evidence) ? "
|
|
242
|
+
blocker = payment !== "captured" ? "Payment capture" : !evidencePermitsRelease(evidence) ? "Evidence review" :
|
|
243
|
+
contactAvailability.shipTo !== "present" && contactAvailability.shipTo !== "restricted" ? "Ship-to address" : "Manual review";
|
|
238
244
|
}
|
|
239
245
|
|
|
240
246
|
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
@@ -252,6 +258,7 @@ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
|
252
258
|
placedAt,
|
|
253
259
|
currency: entry.currency,
|
|
254
260
|
customer: { id: null, name: "Checkout customer" },
|
|
261
|
+
contactAvailability,
|
|
255
262
|
itemsSummary,
|
|
256
263
|
itemCount,
|
|
257
264
|
total: toMajor(totals.totalMinor),
|
|
@@ -320,16 +327,22 @@ const PAYMENT_CONFIRMATION_GRACE_MS = PAYMENT_CONFIRMATION_GRACE_MINUTES * 60 *
|
|
|
320
327
|
export function ledgerOrderBook(
|
|
321
328
|
ledger: Pick<ScrappyOrderLedger, "readOrderBook">,
|
|
322
329
|
scope: { tenantId: string; stage: CommerceStage },
|
|
330
|
+
options: { readContactAvailability?: (orderIds: readonly string[]) => Promise<ReadonlyMap<string, OrderContactAvailability>> } = {},
|
|
323
331
|
): { client: CheckoutOrderClient; status: (now?: Date) => Promise<LedgerSyncStatus> } {
|
|
324
332
|
let loaded: Promise<{ orders: LedgerOrder[]; unconfirmed: CommerceOrderBookEntry[] }> | null = null;
|
|
325
333
|
const load = () =>
|
|
326
|
-
(loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then((book) => {
|
|
334
|
+
(loaded ??= ledger.readOrderBook(scope.tenantId, scope.stage).then(async (book) => {
|
|
327
335
|
const paid = book.filter((entry) => entry.source !== "scrappy");
|
|
328
336
|
const confirmed = new Set(
|
|
329
337
|
paid.flatMap((entry) => entry.events.map((event: CommerceSourceEvent) => event.admissionReference)).filter(Boolean),
|
|
330
338
|
);
|
|
339
|
+
let availability: ReadonlyMap<string, OrderContactAvailability> = new Map();
|
|
340
|
+
try { availability = await options.readContactAvailability?.(paid.map((entry) => entry.orderId)) ?? availability; }
|
|
341
|
+
catch { /* A failed presence read cannot clear shipment readiness. */ }
|
|
331
342
|
return {
|
|
332
|
-
orders: paid.map(ledgerOrder)
|
|
343
|
+
orders: paid.map((entry) => ledgerOrder(entry, availability.get(entry.orderId) ?? {
|
|
344
|
+
identity: "unreadable", email: "unreadable", shipTo: "unreadable",
|
|
345
|
+
})),
|
|
333
346
|
unconfirmed: book.filter((entry) => entry.source === "scrappy" && !confirmed.has(entry.orderId)),
|
|
334
347
|
};
|
|
335
348
|
}));
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
PublicCommerceEvent,
|
|
27
27
|
StockState,
|
|
28
28
|
TenantConfig,
|
|
29
|
+
OrderContactState,
|
|
29
30
|
} from "@tot/public-runtime";
|
|
30
31
|
import {
|
|
31
32
|
actorMayCommand,
|
|
@@ -158,6 +159,7 @@ export type AuthorityState = "fresh" | "expired" | "unreadable" | "absent";
|
|
|
158
159
|
|
|
159
160
|
/** One commerce order. Columns do not borrow each other's state, source, or as-of. */
|
|
160
161
|
export interface ScrappyOrderRecord {
|
|
162
|
+
shipTo?: OrderContactState;
|
|
161
163
|
id: string;
|
|
162
164
|
placedAt: string;
|
|
163
165
|
tenantId: string;
|
|
@@ -264,6 +266,7 @@ function deriveRisk(
|
|
|
264
266
|
|
|
265
267
|
function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
|
|
266
268
|
if (fulfillment === "hold_review_required") {
|
|
269
|
+
if (blocker === "Ship-to address") return "Review address";
|
|
267
270
|
return blocker === "Payment capture" ? "Capture payment" : "Review risk";
|
|
268
271
|
}
|
|
269
272
|
if (fulfillment === "completed") return "View record";
|
|
@@ -348,6 +351,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
|
|
|
348
351
|
if (!evidencePermitsRelease(evidence)) blocks.push("evidence");
|
|
349
352
|
if (order.stock.state !== "confirmed" && !blocks.includes("stock")) blocks.push("stock");
|
|
350
353
|
if (order.fulfillment.state !== "ready" && !blocks.includes("fulfillment")) blocks.push("fulfillment");
|
|
354
|
+
if (!usableContact(order.shipTo)) blocks.push("ship-to");
|
|
351
355
|
|
|
352
356
|
const decision = decideCommerceCommand({
|
|
353
357
|
command: "hand-off",
|
|
@@ -357,6 +361,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
|
|
|
357
361
|
label: order.label,
|
|
358
362
|
evidence,
|
|
359
363
|
providerOutcome: order.providerOutcome,
|
|
364
|
+
shipTo: order.shipTo,
|
|
360
365
|
});
|
|
361
366
|
if (!decision.admit) {
|
|
362
367
|
if (decision.reason === "payment" && !blocks.includes("payment") && !blocks.includes("refund")) blocks.push("payment");
|
|
@@ -383,6 +388,7 @@ function checkoutRecord(order: CheckoutOrderSummary, scope: CommerceScope): Scra
|
|
|
383
388
|
tenantId: scope.tenantId,
|
|
384
389
|
stage: scope.stage,
|
|
385
390
|
currency: order.currency,
|
|
391
|
+
shipTo: order.contactAvailability?.shipTo,
|
|
386
392
|
payment: { state: payment, source: "checkout-order", asOf: order.placedAt },
|
|
387
393
|
tax: readSourcedEvidence(order.verification, "tax"),
|
|
388
394
|
verification: readSourcedEvidence(order.verification, "verification"),
|
|
@@ -415,14 +421,16 @@ export function assessCheckoutOrder(order: CheckoutOrderSummary, scope: Commerce
|
|
|
415
421
|
|
|
416
422
|
export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
|
|
417
423
|
const verificationMissing = s.verification == null;
|
|
418
|
-
const
|
|
419
|
-
const
|
|
424
|
+
const addressMissing = !usableContact(s.contactAvailability?.shipTo);
|
|
425
|
+
const addressHold = s.fulfillment !== "completed" && addressMissing;
|
|
426
|
+
const shipState: OrderShipState = verificationMissing || addressHold ? "hold_review_required" : s.fulfillment;
|
|
427
|
+
const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : addressHold ? "Ship-to address" : null);
|
|
420
428
|
return {
|
|
421
429
|
id: s.id,
|
|
422
430
|
placedAt: s.placedAt,
|
|
423
431
|
customerId: s.customer.id ?? "",
|
|
424
432
|
customerName: s.customer.name,
|
|
425
|
-
customerNote: "",
|
|
433
|
+
customerNote: contactPresenceCopy(s.contactAvailability?.identity, "Customer identity"),
|
|
426
434
|
itemsSummary: s.itemsSummary,
|
|
427
435
|
productId: "",
|
|
428
436
|
total: s.total,
|
|
@@ -434,6 +442,21 @@ export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
|
|
|
434
442
|
};
|
|
435
443
|
}
|
|
436
444
|
|
|
445
|
+
function usableContact(state: OrderContactState | undefined): boolean {
|
|
446
|
+
return state === "present" || state === "restricted";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function contactPresenceCopy(state: OrderContactState | undefined, subject: string): string {
|
|
450
|
+
switch (state) {
|
|
451
|
+
case "present": return `${subject} is on file in the private contact vault. Viewing details requires owner access or a support grant.`;
|
|
452
|
+
case "restricted": return `${subject} is on file; you do not have access to view its details.`;
|
|
453
|
+
case "absent": return `${subject} is not on file.`;
|
|
454
|
+
case "expired": return `${subject} has expired under the contact retention policy.`;
|
|
455
|
+
case "erased": return `${subject} has been erased.`;
|
|
456
|
+
default: return `${subject} availability could not be verified. Try again shortly.`;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
437
460
|
function paymentValue(payment: CheckoutOrderPayment): string {
|
|
438
461
|
if (payment === "captured") return "Captured";
|
|
439
462
|
if (payment === "refunded") return "Refunded";
|
|
@@ -489,13 +512,11 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
|
|
|
489
512
|
{
|
|
490
513
|
key: "address",
|
|
491
514
|
label: "Address",
|
|
492
|
-
value: d.shippingAddress ? "
|
|
493
|
-
detail: d.shippingAddress
|
|
494
|
-
|
|
495
|
-
: "No address is on the order.",
|
|
496
|
-
tone: d.shippingAddress ? "green" : "amber",
|
|
515
|
+
value: d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : "Unknown"),
|
|
516
|
+
detail: contactPresenceCopy(d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : undefined), "Ship-to address"),
|
|
517
|
+
tone: usableContact(d.contactAvailability?.shipTo) || (!d.contactAvailability && d.shippingAddress) ? "green" : "amber",
|
|
497
518
|
action: "View address",
|
|
498
|
-
blocking:
|
|
519
|
+
blocking: !usableContact(d.contactAvailability?.shipTo) && !(!d.contactAvailability && d.shippingAddress),
|
|
499
520
|
},
|
|
500
521
|
columnCheck(column("risk"), {
|
|
501
522
|
label: "Risk",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* Nothing here logs. Errors and outcomes carry fixed codes only; fact_id never leaves this module.
|
|
19
19
|
*/
|
|
20
20
|
import type { Queryable, QueryStatement } from "../../catalog/d1/catalog.js";
|
|
21
|
+
import type { OrderContactAvailability, OrderContactState } from "@tot/public-runtime";
|
|
21
22
|
import { VaultKeyUnavailableError, type DataKeyPurpose, type EncryptionContext, type KmsClient } from "@/lib/customers/contactVault/kms.js";
|
|
22
23
|
import {
|
|
23
24
|
activeKey,
|
|
@@ -600,6 +601,96 @@ const ORDER_JOIN_SELECT = `SELECT v.*, o.placed_at AS order_placed_at, o.fulfill
|
|
|
600
601
|
FROM scrappy_commerce_orders o
|
|
601
602
|
JOIN order_contact_vault v ON v.tenant_id = o.tenant_id AND v.stage = o.stage AND v.order_id = o.order_id`;
|
|
602
603
|
|
|
604
|
+
const AVAILABILITY_GROUPS = ["identity", "email", "ship"] as const;
|
|
605
|
+
const AVAILABILITY_CHUNK_SIZE = 80;
|
|
606
|
+
|
|
607
|
+
/** Structural envelope presence only; authentication belongs to an audited contact read. */
|
|
608
|
+
function envelopePresentSql(column: string): string {
|
|
609
|
+
return `(typeof(${column}) = 'text' AND length(${column}) >= 40 AND length(${column}) % 4 = 0
|
|
610
|
+
AND rtrim(${column}, '=') NOT GLOB '*[^A-Za-z0-9+/]*'
|
|
611
|
+
AND length(${column}) - length(rtrim(${column}, '=')) <= 2)`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function completeGroupSql(group: typeof AVAILABILITY_GROUPS[number]): string {
|
|
615
|
+
const required = group === "identity" ? "name_ct" : group === "email" ? "email_ct" : "ship_line1_ct";
|
|
616
|
+
const encrypted = LAYOUT[group].sealed.map((column) => column === required
|
|
617
|
+
? envelopePresentSql(`v.${column}`)
|
|
618
|
+
: `(v.${column} IS NULL OR ${envelopePresentSql(`v.${column}`)})`);
|
|
619
|
+
if (group === "ship") {
|
|
620
|
+
encrypted.push(...["ship_city", "ship_country"].map((column) => `(typeof(v.${column}) = 'text' AND length(trim(v.${column})) > 0)`));
|
|
621
|
+
}
|
|
622
|
+
return `(${encrypted.join(" AND ")})`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const AVAILABILITY_SELECT = `SELECT o.order_id, o.placed_at AS order_placed_at,
|
|
626
|
+
o.fulfillment_state AS order_fulfillment_state, (${correlatedTerminalSql("o")}) AS order_terminal_at,
|
|
627
|
+
v.order_id IS NOT NULL AS vault_present, v.erased_at IS NOT NULL AS erased,
|
|
628
|
+
EXISTS (SELECT 1 FROM order_contact_markers m
|
|
629
|
+
WHERE m.tenant_id = o.tenant_id AND m.stage = o.stage AND m.order_id = o.order_id) AS marked,
|
|
630
|
+
EXISTS (SELECT 1 FROM contact_vault_data_keys k
|
|
631
|
+
WHERE k.tenant_id = v.tenant_id AND k.stage = v.stage AND k.purpose = 'order-contact' AND k.kid = v.order_kid
|
|
632
|
+
AND typeof(k.wrapped_key) = 'text' AND length(trim(k.wrapped_key)) > 0
|
|
633
|
+
AND typeof(k.kms_key_arn) = 'text' AND length(trim(k.kms_key_arn)) > 0) AS key_present,
|
|
634
|
+
v.time_scale, v.ship_country, v.ship_region,
|
|
635
|
+
${AVAILABILITY_GROUPS.flatMap((group) => [
|
|
636
|
+
// Sentinel columns let the shared retention reader see presence without selecting values.
|
|
637
|
+
`CASE WHEN ${hasDataSql(group, "v.")} THEN 1 END AS ${LAYOUT[group].sealed[0]}`,
|
|
638
|
+
`${completeGroupSql(group)} AS ${group}_complete`,
|
|
639
|
+
...["ceiling", "expires_at", "floor_until"].map((suffix) => `v.${group}_${suffix}`),
|
|
640
|
+
]).join(",\n ")}
|
|
641
|
+
FROM scrappy_commerce_orders o
|
|
642
|
+
LEFT JOIN order_contact_vault v ON v.tenant_id = o.tenant_id AND v.stage = o.stage AND v.order_id = o.order_id`;
|
|
643
|
+
|
|
644
|
+
function uniformAvailability(state: OrderContactState): OrderContactAvailability {
|
|
645
|
+
return { identity: state, email: state, shipTo: state };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function contactAvailability(row: VaultRow, input: {
|
|
649
|
+
tenantId: string; stage: VaultStage; now: Date; policy: RetentionPolicy;
|
|
650
|
+
}): OrderContactAvailability {
|
|
651
|
+
if (Number(row.vault_present) !== 1) return uniformAvailability(Number(row.marked) === 1 ? "expired" : "absent");
|
|
652
|
+
const times = orderTimes(row.order_placed_at, row.order_fulfillment_state, row.order_terminal_at);
|
|
653
|
+
if (times === "order-time-unreadable" || !Number.isFinite(input.now.getTime())) return uniformAvailability("unreadable");
|
|
654
|
+
const states = groupStates(row, input.tenantId, input.stage, times, input.now, input.policy);
|
|
655
|
+
const state = (group: typeof AVAILABILITY_GROUPS[number]): OrderContactState => {
|
|
656
|
+
const current = states[group];
|
|
657
|
+
if (!current.sealed && Number(row.erased) === 1) return "erased";
|
|
658
|
+
if (!current.expiresAt) return "unreadable";
|
|
659
|
+
if (!isGroupReadable(input.now, current.expiresAt, text(row, `${group}_ceiling`)) ||
|
|
660
|
+
(input.stage === "preview" && input.now.getTime() >= Date.parse(previewDeleteAt(times.placedAt, input.policy)))) return "expired";
|
|
661
|
+
if (!current.sealed) return "absent";
|
|
662
|
+
if (Number(row.key_present) !== 1 || Number(row[`${group}_complete`]) !== 1) return "unreadable";
|
|
663
|
+
return current.readable ? "present" : "unreadable";
|
|
664
|
+
};
|
|
665
|
+
return { identity: state("identity"), email: state("email"), shipTo: state("ship") };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Retained contact presence for a bounded order set; no decrypt, audit write, or private values. */
|
|
669
|
+
export async function readOrderContactAvailability(
|
|
670
|
+
db: { all<T extends Record<string, unknown>>(sql: string, ...params: unknown[]): Promise<T[]> },
|
|
671
|
+
input: { tenantId: string; stage: VaultStage; orderIds: readonly string[]; now: Date; policy?: RetentionPolicy },
|
|
672
|
+
): Promise<ReadonlyMap<string, OrderContactAvailability>> {
|
|
673
|
+
const orderIds = [...new Set(input.orderIds)];
|
|
674
|
+
const result = new Map(orderIds.map((id) => [id, uniformAvailability("absent")]));
|
|
675
|
+
const policy = input.policy ?? RETENTION_POLICY;
|
|
676
|
+
try {
|
|
677
|
+
for (let start = 0; start < orderIds.length; start += AVAILABILITY_CHUNK_SIZE) {
|
|
678
|
+
const chunk = orderIds.slice(start, start + AVAILABILITY_CHUNK_SIZE);
|
|
679
|
+
const rows = await db.all<VaultRow>(
|
|
680
|
+
`${AVAILABILITY_SELECT} WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id IN (${chunk.map(() => "?").join(", ")})`,
|
|
681
|
+
input.tenantId, input.stage, ...chunk,
|
|
682
|
+
);
|
|
683
|
+
for (const row of rows) {
|
|
684
|
+
const id = text(row, "order_id");
|
|
685
|
+
if (id !== null && chunk.includes(id)) result.set(id, contactAvailability(row, { ...input, policy }));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return result;
|
|
689
|
+
} catch {
|
|
690
|
+
return new Map(orderIds.map((id) => [id, uniformAvailability("unreadable")]));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
603
694
|
interface Viewable {
|
|
604
695
|
groups: Group[];
|
|
605
696
|
views: Partial<Record<Group, GroupView>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.1.0",
|
|
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",
|
|
@@ -27,6 +27,15 @@ export interface CheckoutOrderCustomer {
|
|
|
27
27
|
name: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** Operational presence only. No customer or address values cross this boundary. */
|
|
31
|
+
export type OrderContactState = "present" | "absent" | "restricted" | "expired" | "erased" | "unreadable";
|
|
32
|
+
|
|
33
|
+
export interface OrderContactAvailability {
|
|
34
|
+
identity: OrderContactState;
|
|
35
|
+
email: OrderContactState;
|
|
36
|
+
shipTo: OrderContactState;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
/** A row in the orders list. All fields populated by U1 EXCEPT `verification`. */
|
|
31
40
|
export interface CheckoutOrderSummary {
|
|
32
41
|
/** Order / transaction id (string). */
|
|
@@ -35,6 +44,8 @@ export interface CheckoutOrderSummary {
|
|
|
35
44
|
placedAt: string;
|
|
36
45
|
currency: string;
|
|
37
46
|
customer: CheckoutOrderCustomer;
|
|
47
|
+
/** Sealed contact availability, independent of permission to read its values. */
|
|
48
|
+
contactAvailability?: OrderContactAvailability;
|
|
38
49
|
/** Human one-liner, e.g. "Vape Pen Starter Kit +1 more". */
|
|
39
50
|
itemsSummary: string;
|
|
40
51
|
itemCount: number;
|
|
@@ -300,6 +311,8 @@ export type CommerceCommand =
|
|
|
300
311
|
| "reconcile";
|
|
301
312
|
|
|
302
313
|
export interface CommerceCommandInput {
|
|
314
|
+
/** Current server-read presence; shipment commands fail closed when it is unknown. */
|
|
315
|
+
shipTo?: OrderContactState;
|
|
303
316
|
command: CommerceCommand;
|
|
304
317
|
payment: PaymentState;
|
|
305
318
|
stock: StockState;
|
|
@@ -311,7 +324,7 @@ export interface CommerceCommandInput {
|
|
|
311
324
|
|
|
312
325
|
export interface CommerceCommandDecision {
|
|
313
326
|
admit: boolean;
|
|
314
|
-
reason: "admitted" | "evidence" | "payment" | "stock" | "fulfillment" | "label" | "reconcile" | "reuse";
|
|
327
|
+
reason: "admitted" | "evidence" | "payment" | "stock" | "fulfillment" | "label" | "reconcile" | "reuse" | "ship-to";
|
|
315
328
|
restock: false;
|
|
316
329
|
releaseReservation: boolean;
|
|
317
330
|
blindRepurchase: false;
|
|
@@ -601,6 +614,7 @@ export function decideCommerceCommand(input: CommerceCommandInput): CommerceComm
|
|
|
601
614
|
if (input.stock !== "confirmed") return deny("stock");
|
|
602
615
|
if (input.fulfillment !== "ready") return deny("fulfillment");
|
|
603
616
|
if (input.label !== "none" && input.label !== "voided") return deny("label");
|
|
617
|
+
if (input.shipTo !== "present" && input.shipTo !== "restricted") return deny("ship-to");
|
|
604
618
|
return allow();
|
|
605
619
|
case "print-label":
|
|
606
620
|
if (input.fulfillment === "cancelled") return deny("fulfillment");
|
|
@@ -613,6 +627,7 @@ export function decideCommerceCommand(input: CommerceCommandInput): CommerceComm
|
|
|
613
627
|
if (input.stock !== "confirmed") return deny("stock");
|
|
614
628
|
if (input.fulfillment !== "ready") return deny("fulfillment");
|
|
615
629
|
if (input.label !== "purchased" && input.label !== "printed") return deny("label");
|
|
630
|
+
if (input.shipTo !== "present" && input.shipTo !== "restricted") return deny("ship-to");
|
|
616
631
|
return allow();
|
|
617
632
|
default: {
|
|
618
633
|
const unreachable: never = input.command;
|