@tokenoftrust/storefront-runner 6.0.2 → 6.1.1
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/orderClient.ts +83 -13
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +21 -8
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +37 -11
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +91 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +3 -1
- package/apps/storefront/src/pages/api/admin/orders.ts +7 -5
- 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";
|
|
@@ -14,16 +14,21 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type {
|
|
16
16
|
CheckoutOrderDetail,
|
|
17
|
-
|
|
17
|
+
CheckoutOrderSummary,
|
|
18
18
|
CheckoutOrderListFilters,
|
|
19
19
|
CheckoutOrderListResponse,
|
|
20
20
|
CommerceActor,
|
|
21
21
|
CommerceRole,
|
|
22
22
|
CommerceScope,
|
|
23
23
|
CommerceStage,
|
|
24
|
+
CheckoutOrderCustomer,
|
|
25
|
+
OrderContactAvailability,
|
|
26
|
+
OrderContactState,
|
|
24
27
|
} from "@tot/public-runtime";
|
|
25
|
-
import { readOwnerAddress } from "@tot/public-runtime";
|
|
28
|
+
import { actorMayCommand, readOwnerAddress } from "@tot/public-runtime";
|
|
26
29
|
import type { CustomizationEnv } from "@tot/public-runtime";
|
|
30
|
+
import { contactReaderAuthority } from "@/lib/access/auth/contactReaderAuthority";
|
|
31
|
+
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
27
32
|
|
|
28
33
|
/** Fetch shape we depend on — the platform `fetch`, injectable for tests. */
|
|
29
34
|
export type FetchLike = (
|
|
@@ -107,26 +112,83 @@ export function commerceStageForCustomization(env: CustomizationEnv): CommerceSt
|
|
|
107
112
|
return env === "live" ? "publication" : "preview";
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
/**
|
|
115
|
+
/** Private ownership comes from tenant membership, independently of staff operational capability. */
|
|
111
116
|
export function commerceActorForAdmin(input: {
|
|
112
117
|
tenantId: string;
|
|
113
118
|
stage: CommerceStage;
|
|
114
|
-
|
|
119
|
+
record: SessionRecord | null | undefined;
|
|
120
|
+
nowSeconds: number;
|
|
115
121
|
admitted: boolean;
|
|
116
122
|
}): CommerceActor {
|
|
117
|
-
const
|
|
123
|
+
const privateOwner = contactReaderAuthority(input.record, input.tenantId, input.nowSeconds).kind === "owner";
|
|
124
|
+
const role: CommerceRole = !input.admitted ? "denied" : privateOwner ? "owner" : "caller";
|
|
118
125
|
return { role, tenantId: input.tenantId, stage: input.stage };
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
/** Drop the address unless this actor may read it for this tenant and stage. */
|
|
122
|
-
export function projectCheckoutOrder<T extends { shippingAddress?: unknown }>(
|
|
129
|
+
export function projectCheckoutOrder<T extends { shippingAddress?: unknown; customer?: CheckoutOrderCustomer; contactAvailability?: OrderContactAvailability }>(
|
|
123
130
|
order: T,
|
|
124
131
|
actor: CommerceActor,
|
|
125
132
|
resource: CommerceScope,
|
|
126
133
|
): T {
|
|
134
|
+
const unknown: OrderContactAvailability = { identity: "unreadable", email: "unreadable", shipTo: "unreadable" };
|
|
135
|
+
const availability = order.contactAvailability ?? {
|
|
136
|
+
...unknown,
|
|
137
|
+
shipTo: Object.hasOwn(order, "shippingAddress") ? addressAvailability(order.shippingAddress) : "unreadable",
|
|
138
|
+
};
|
|
127
139
|
const read = readOwnerAddress(actor, resource, () => order.shippingAddress ?? null);
|
|
128
|
-
if (read.ok) return order;
|
|
129
|
-
|
|
140
|
+
if (read.ok) return { ...order, contactAvailability: availability };
|
|
141
|
+
const restricted = (state: OrderContactState): OrderContactState => state === "present" ? "restricted" : state;
|
|
142
|
+
const projected: Record<string, unknown> = { ...order };
|
|
143
|
+
for (const key of ["customerName", "customerEmail", "email", "phone", "name", "line1", "line2", "postalCode"]) delete projected[key];
|
|
144
|
+
return {
|
|
145
|
+
...projected,
|
|
146
|
+
shippingAddress: null,
|
|
147
|
+
...(order.customer ? { customer: { id: null, name: "Checkout customer" } } : {}),
|
|
148
|
+
contactAvailability: actorMayCommand(actor, resource) ? {
|
|
149
|
+
identity: restricted(availability.identity), email: restricted(availability.email), shipTo: restricted(availability.shipTo),
|
|
150
|
+
} : unknown,
|
|
151
|
+
} as T;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function addressAvailability(value: unknown): OrderContactState {
|
|
155
|
+
if (value === null) return "absent";
|
|
156
|
+
if (!isRecord(value)) return "unreadable";
|
|
157
|
+
return ["line1", "city", "country"].every((key) => typeof value[key] === "string" && value[key].trim() !== "") ? "present" : "unreadable";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
161
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const SUMMARY_FIELDS = ["id", "placedAt", "currency", "customer", "contactAvailability", "itemsSummary", "itemCount", "total", "payment", "fulfillment", "blocker", "verification"];
|
|
165
|
+
const DETAIL_FIELDS = [...SUMMARY_FIELDS, "lineItems", "totals", "shippingAddress", "verificationDetail"];
|
|
166
|
+
|
|
167
|
+
/** Refuse an unsupported upstream body before any raw contact aliases reach an admin response. */
|
|
168
|
+
function frozenOrder(value: unknown, detail: true): value is CheckoutOrderDetail;
|
|
169
|
+
function frozenOrder(value: unknown, detail: false): value is CheckoutOrderSummary;
|
|
170
|
+
function frozenOrder(value: unknown, detail: boolean): boolean {
|
|
171
|
+
if (!isRecord(value) || Object.keys(value).some((key) => !(detail ? DETAIL_FIELDS : SUMMARY_FIELDS).includes(key))) return false;
|
|
172
|
+
if (!["id", "placedAt", "currency", "itemsSummary"].every((key) => typeof value[key] === "string")) return false;
|
|
173
|
+
if (!isRecord(value.customer) || typeof value.customer.name !== "string" ||
|
|
174
|
+
!(value.customer.id === null || typeof value.customer.id === "string") ||
|
|
175
|
+
Object.keys(value.customer).some((key) => key !== "id" && key !== "name")) return false;
|
|
176
|
+
if (typeof value.total !== "number" || !Number.isFinite(value.total) || !Number.isInteger(value.itemCount)) return false;
|
|
177
|
+
if (!["authorized", "captured", "refunded"].includes(String(value.payment)) ||
|
|
178
|
+
!["unfulfilled", "ready_to_ship", "hold_review_required", "completed"].includes(String(value.fulfillment))) return false;
|
|
179
|
+
if (value.blocker !== null && typeof value.blocker !== "string") return false;
|
|
180
|
+
if (value.verification !== null && !isRecord(value.verification)) return false;
|
|
181
|
+
const availability = value.contactAvailability;
|
|
182
|
+
if (availability !== undefined && (!isRecord(availability) ||
|
|
183
|
+
!["identity", "email", "shipTo"].every((key) => typeof availability[key] === "string" &&
|
|
184
|
+
["present", "restricted", "absent", "expired", "erased", "unreadable"].includes(availability[key])) ||
|
|
185
|
+
Object.keys(availability).some((key) => !["identity", "email", "shipTo"].includes(key)))) return false;
|
|
186
|
+
return !detail || (Array.isArray(value.lineItems) && isRecord(value.totals) && isRecord(value.verificationDetail) &&
|
|
187
|
+
(value.shippingAddress === null || isRecord(value.shippingAddress)));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function unsupportedBody(): OrderApiError {
|
|
191
|
+
return new OrderApiError("bad_gateway", 502);
|
|
130
192
|
}
|
|
131
193
|
|
|
132
194
|
/** Build the list querystring: appDomain (required) + any provided filters. */
|
|
@@ -151,12 +213,12 @@ export function createCheckoutOrderClient(
|
|
|
151
213
|
const base = config.baseUrl.replace(/\/+$/, "");
|
|
152
214
|
const headers = { authorization: `Bearer ${config.bearer}` };
|
|
153
215
|
|
|
154
|
-
async function getJson
|
|
216
|
+
async function getJson(url: string): Promise<unknown> {
|
|
155
217
|
const res = await doFetch(url, { method: "GET", headers });
|
|
156
218
|
const body = await res.text();
|
|
157
219
|
if (!res.ok) throw toError(res.status, body);
|
|
158
220
|
try {
|
|
159
|
-
return JSON.parse(body) as
|
|
221
|
+
return JSON.parse(body) as unknown;
|
|
160
222
|
} catch {
|
|
161
223
|
throw new OrderApiError("unexpected", res.status, "malformed JSON");
|
|
162
224
|
}
|
|
@@ -165,14 +227,22 @@ export function createCheckoutOrderClient(
|
|
|
165
227
|
return {
|
|
166
228
|
async listOrders(filters) {
|
|
167
229
|
const qs = listQuery(config.appDomain, filters);
|
|
168
|
-
|
|
230
|
+
const body = await getJson(`${base}/commerce/orders?${qs}`);
|
|
231
|
+
if (!isRecord(body) || !Array.isArray(body.orders) || !body.orders.every((order): order is CheckoutOrderSummary => frozenOrder(order, false)) ||
|
|
232
|
+
!isRecord(body.page) || Object.keys(body).some((key) => key !== "orders" && key !== "page")) throw unsupportedBody();
|
|
233
|
+
const page = body.page;
|
|
234
|
+
if (typeof page.limit !== "number" || !(page.cursor === null || typeof page.cursor === "string") ||
|
|
235
|
+
!(page.nextCursor === null || typeof page.nextCursor === "string") || !(page.total === null || typeof page.total === "number") ||
|
|
236
|
+
Object.keys(page).some((key) => !["limit", "cursor", "nextCursor", "total"].includes(key))) throw unsupportedBody();
|
|
237
|
+
return { orders: body.orders, page: { limit: page.limit, cursor: page.cursor, nextCursor: page.nextCursor, total: page.total } };
|
|
169
238
|
},
|
|
170
239
|
async getOrder(orderId) {
|
|
171
240
|
const qs = new URLSearchParams({ appDomain: config.appDomain }).toString();
|
|
172
|
-
const
|
|
241
|
+
const body = await getJson(
|
|
173
242
|
`${base}/commerce/orders/${encodeURIComponent(orderId)}?${qs}`,
|
|
174
243
|
);
|
|
175
|
-
|
|
244
|
+
if (!isRecord(body) || !frozenOrder(body.order, true) || Object.keys(body).some((key) => key !== "order")) throw unsupportedBody();
|
|
245
|
+
return body.order;
|
|
176
246
|
},
|
|
177
247
|
};
|
|
178
248
|
}
|
|
@@ -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,7 +26,9 @@ import type {
|
|
|
26
26
|
PublicCommerceEvent,
|
|
27
27
|
StockState,
|
|
28
28
|
TenantConfig,
|
|
29
|
+
OrderContactState,
|
|
29
30
|
} from "@tot/public-runtime";
|
|
31
|
+
import { projectCheckoutOrder } from "../orderClient";
|
|
30
32
|
import {
|
|
31
33
|
actorMayCommand,
|
|
32
34
|
decideCommerceCommand,
|
|
@@ -158,6 +160,7 @@ export type AuthorityState = "fresh" | "expired" | "unreadable" | "absent";
|
|
|
158
160
|
|
|
159
161
|
/** One commerce order. Columns do not borrow each other's state, source, or as-of. */
|
|
160
162
|
export interface ScrappyOrderRecord {
|
|
163
|
+
shipTo?: OrderContactState;
|
|
161
164
|
id: string;
|
|
162
165
|
placedAt: string;
|
|
163
166
|
tenantId: string;
|
|
@@ -264,6 +267,7 @@ function deriveRisk(
|
|
|
264
267
|
|
|
265
268
|
function deriveNextStep(fulfillment: OrderShipState, blocker: string | null): string {
|
|
266
269
|
if (fulfillment === "hold_review_required") {
|
|
270
|
+
if (blocker === "Ship-to address") return "Review address";
|
|
267
271
|
return blocker === "Payment capture" ? "Capture payment" : "Review risk";
|
|
268
272
|
}
|
|
269
273
|
if (fulfillment === "completed") return "View record";
|
|
@@ -348,6 +352,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
|
|
|
348
352
|
if (!evidencePermitsRelease(evidence)) blocks.push("evidence");
|
|
349
353
|
if (order.stock.state !== "confirmed" && !blocks.includes("stock")) blocks.push("stock");
|
|
350
354
|
if (order.fulfillment.state !== "ready" && !blocks.includes("fulfillment")) blocks.push("fulfillment");
|
|
355
|
+
if (!usableContact(order.shipTo)) blocks.push("ship-to");
|
|
351
356
|
|
|
352
357
|
const decision = decideCommerceCommand({
|
|
353
358
|
command: "hand-off",
|
|
@@ -357,6 +362,7 @@ export function releaseOf(order: ScrappyOrderRecord): OrderRelease {
|
|
|
357
362
|
label: order.label,
|
|
358
363
|
evidence,
|
|
359
364
|
providerOutcome: order.providerOutcome,
|
|
365
|
+
shipTo: order.shipTo,
|
|
360
366
|
});
|
|
361
367
|
if (!decision.admit) {
|
|
362
368
|
if (decision.reason === "payment" && !blocks.includes("payment") && !blocks.includes("refund")) blocks.push("payment");
|
|
@@ -383,6 +389,7 @@ function checkoutRecord(order: CheckoutOrderSummary, scope: CommerceScope): Scra
|
|
|
383
389
|
tenantId: scope.tenantId,
|
|
384
390
|
stage: scope.stage,
|
|
385
391
|
currency: order.currency,
|
|
392
|
+
shipTo: order.contactAvailability?.shipTo,
|
|
386
393
|
payment: { state: payment, source: "checkout-order", asOf: order.placedAt },
|
|
387
394
|
tax: readSourcedEvidence(order.verification, "tax"),
|
|
388
395
|
verification: readSourcedEvidence(order.verification, "verification"),
|
|
@@ -415,14 +422,16 @@ export function assessCheckoutOrder(order: CheckoutOrderSummary, scope: Commerce
|
|
|
415
422
|
|
|
416
423
|
export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
|
|
417
424
|
const verificationMissing = s.verification == null;
|
|
418
|
-
const
|
|
419
|
-
const
|
|
425
|
+
const addressMissing = !usableContact(s.contactAvailability?.shipTo);
|
|
426
|
+
const addressHold = s.fulfillment !== "completed" && addressMissing;
|
|
427
|
+
const shipState: OrderShipState = verificationMissing || addressHold ? "hold_review_required" : s.fulfillment;
|
|
428
|
+
const blocker = s.blocker ?? (verificationMissing ? "Verification unknown" : addressHold ? "Ship-to address" : null);
|
|
420
429
|
return {
|
|
421
430
|
id: s.id,
|
|
422
431
|
placedAt: s.placedAt,
|
|
423
432
|
customerId: s.customer.id ?? "",
|
|
424
433
|
customerName: s.customer.name,
|
|
425
|
-
customerNote: "",
|
|
434
|
+
customerNote: contactPresenceCopy(s.contactAvailability?.identity, "Customer identity"),
|
|
426
435
|
itemsSummary: s.itemsSummary,
|
|
427
436
|
productId: "",
|
|
428
437
|
total: s.total,
|
|
@@ -434,6 +443,21 @@ export function mapSummaryToRow(s: CheckoutOrderSummary): OrderRow {
|
|
|
434
443
|
};
|
|
435
444
|
}
|
|
436
445
|
|
|
446
|
+
function usableContact(state: OrderContactState | undefined): boolean {
|
|
447
|
+
return state === "present" || state === "restricted";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function contactPresenceCopy(state: OrderContactState | undefined, subject: string): string {
|
|
451
|
+
switch (state) {
|
|
452
|
+
case "present": return `${subject} is on file in the private contact vault. Viewing details requires owner access or a support grant.`;
|
|
453
|
+
case "restricted": return `${subject} is on file; you do not have access to view its details.`;
|
|
454
|
+
case "absent": return `${subject} is not on file.`;
|
|
455
|
+
case "expired": return `${subject} has expired under the contact retention policy.`;
|
|
456
|
+
case "erased": return `${subject} has been erased.`;
|
|
457
|
+
default: return `${subject} availability could not be verified. Try again shortly.`;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
437
461
|
function paymentValue(payment: CheckoutOrderPayment): string {
|
|
438
462
|
if (payment === "captured") return "Captured";
|
|
439
463
|
if (payment === "refunded") return "Refunded";
|
|
@@ -489,13 +513,11 @@ function deriveReadinessChecks(d: CheckoutOrderDetail, scope: CommerceScope): Or
|
|
|
489
513
|
{
|
|
490
514
|
key: "address",
|
|
491
515
|
label: "Address",
|
|
492
|
-
value: d.shippingAddress ? "
|
|
493
|
-
detail: d.shippingAddress
|
|
494
|
-
|
|
495
|
-
: "No address is on the order.",
|
|
496
|
-
tone: d.shippingAddress ? "green" : "amber",
|
|
516
|
+
value: d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : "Unknown"),
|
|
517
|
+
detail: contactPresenceCopy(d.contactAvailability?.shipTo ?? (d.shippingAddress ? "present" : undefined), "Ship-to address"),
|
|
518
|
+
tone: usableContact(d.contactAvailability?.shipTo) || (!d.contactAvailability && d.shippingAddress) ? "green" : "amber",
|
|
497
519
|
action: "View address",
|
|
498
|
-
blocking:
|
|
520
|
+
blocking: !usableContact(d.contactAvailability?.shipTo) && !(!d.contactAvailability && d.shippingAddress),
|
|
499
521
|
},
|
|
500
522
|
columnCheck(column("risk"), {
|
|
501
523
|
label: "Risk",
|
|
@@ -623,6 +645,7 @@ export function emptyOrdersViewModel(targetTenant: string, timeZone: string = DE
|
|
|
623
645
|
}
|
|
624
646
|
|
|
625
647
|
export interface GetOrdersViewModelOptions {
|
|
648
|
+
actor?: CommerceActor;
|
|
626
649
|
targetTenant: string;
|
|
627
650
|
scope: CommerceScope;
|
|
628
651
|
client: CheckoutOrderClient;
|
|
@@ -698,12 +721,15 @@ async function readOrdersToday(client: CheckoutOrderClient, now: Date, timeZone:
|
|
|
698
721
|
|
|
699
722
|
export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
|
|
700
723
|
try {
|
|
701
|
-
const
|
|
724
|
+
const listed = await opts.client.listOrders(opts.filters);
|
|
725
|
+
const actor = opts.actor ?? { ...opts.scope, role: "caller" as const };
|
|
726
|
+
const orders = listed.orders.map((order) => projectCheckoutOrder(order, actor, opts.scope));
|
|
727
|
+
const page = listed.page;
|
|
702
728
|
const details: Record<string, CheckoutOrderDetail> = {};
|
|
703
729
|
await Promise.all(
|
|
704
730
|
orders.map(async (o) => {
|
|
705
731
|
try {
|
|
706
|
-
details[o.id] = await opts.client.getOrder(o.id);
|
|
732
|
+
details[o.id] = projectCheckoutOrder(await opts.client.getOrder(o.id), actor, opts.scope);
|
|
707
733
|
} catch {
|
|
708
734
|
/* detail unavailable — row still renders from its summary */
|
|
709
735
|
}
|
|
@@ -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>>;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { APIContext } from "astro";
|
|
9
9
|
import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
|
|
10
|
+
import { readViewerSession } from "@/lib/access/auth/route";
|
|
10
11
|
import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
|
|
11
12
|
import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
|
|
12
13
|
import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
@@ -47,7 +48,8 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
47
48
|
const actor = commerceActorForAdmin({
|
|
48
49
|
tenantId: scope.tenantId,
|
|
49
50
|
stage: scope.stage,
|
|
50
|
-
|
|
51
|
+
record: await readViewerSession(context),
|
|
52
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
51
53
|
admitted: access.entry.admitted,
|
|
52
54
|
});
|
|
53
55
|
const order = projectCheckoutOrder(await source.client.getOrder(orderId), actor, scope);
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { APIContext } from "astro";
|
|
13
13
|
import { requireAdminApiEntry } from "@/lib/access/auth/adminApiGuard";
|
|
14
|
-
import {
|
|
14
|
+
import { readViewerSession } from "@/lib/access/auth/route";
|
|
15
|
+
import { commerceActorForAdmin, OrderApiError, projectCheckoutOrder } from "@/lib/orders/admin/orderClient";
|
|
15
16
|
import { resolveOrderSource, workerOrderSourceDeps } from "@/lib/orders/admin/orderSource";
|
|
16
17
|
import { admitSharedStoreTenant } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
17
18
|
import { assessCheckoutOrder, parseOrderFilters } from "@/lib/orders/admin/services/orders";
|
|
@@ -51,10 +52,11 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
51
52
|
|
|
52
53
|
try {
|
|
53
54
|
const data = await source.client.listOrders(filters);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const actor = commerceActorForAdmin({ ...source.scope, record: await readViewerSession(context), nowSeconds: Math.floor(Date.now() / 1000), admitted: access.entry.admitted });
|
|
56
|
+
const orders = data.orders.map((raw) => {
|
|
57
|
+
const order = projectCheckoutOrder(raw, actor, source.scope);
|
|
58
|
+
return { ...order, readiness: assessCheckoutOrder(order, source.scope) };
|
|
59
|
+
});
|
|
58
60
|
return new Response(JSON.stringify({ ...data, orders }), { headers: JSON_HEADERS });
|
|
59
61
|
} catch (err) {
|
|
60
62
|
if (err instanceof OrderApiError) return jsonError(err.code, err.status);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.1",
|
|
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;
|