@tokenoftrust/storefront-runner 4.7.6 → 4.7.8
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/checkout/commerce/commerceAdmission.ts +92 -2
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +3 -34
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +196 -0
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +7 -5
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +17 -1
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +37 -10
- package/apps/storefront/src/pages/_sitemap.ts +11 -2
- package/apps/storefront/src/pages/sitemap-[child].xml.ts +1 -1
- package/package.json +1 -1
- package/packages/cli/src/publication-spaces.mjs +7 -2
- package/packages/public-runtime/src/sitemap-index.mjs +4 -3
|
@@ -51,9 +51,39 @@ export async function readCommerceAdmission(
|
|
|
51
51
|
stage,
|
|
52
52
|
authorizationId,
|
|
53
53
|
);
|
|
54
|
-
|
|
54
|
+
return row ? parseCommerceAdmission(row.snapshot_json, tenantId, stage, authorizationId) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every admission snapshot one tenant and stage holds, keyed by authorization id. A snapshot that fails its own checks is absent. */
|
|
58
|
+
export async function readCommerceAdmissions(
|
|
59
|
+
db: Queryable,
|
|
60
|
+
tenantId: string,
|
|
61
|
+
stage: CommerceStage,
|
|
62
|
+
authorizationIds: ReadonlySet<string>,
|
|
63
|
+
): Promise<Map<string, CommerceAdmissionSnapshot>> {
|
|
64
|
+
const admissions = new Map<string, CommerceAdmissionSnapshot>();
|
|
65
|
+
if (authorizationIds.size === 0) return admissions;
|
|
66
|
+
const rows = await db.all<{ authorization_id: string; snapshot_json: string }>(
|
|
67
|
+
`SELECT authorization_id,snapshot_json FROM commerce_admission_snapshots WHERE tenant_id=? AND stage=?`,
|
|
68
|
+
tenantId,
|
|
69
|
+
stage,
|
|
70
|
+
);
|
|
71
|
+
for (const row of rows) {
|
|
72
|
+
if (!authorizationIds.has(row.authorization_id)) continue;
|
|
73
|
+
const admission = parseCommerceAdmission(row.snapshot_json, tenantId, stage, row.authorization_id);
|
|
74
|
+
if (admission) admissions.set(row.authorization_id, admission);
|
|
75
|
+
}
|
|
76
|
+
return admissions;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseCommerceAdmission(
|
|
80
|
+
snapshotJson: string,
|
|
81
|
+
tenantId: string,
|
|
82
|
+
stage: CommerceStage,
|
|
83
|
+
authorizationId: string,
|
|
84
|
+
): CommerceAdmissionSnapshot | null {
|
|
55
85
|
try {
|
|
56
|
-
const snapshot = JSON.parse(
|
|
86
|
+
const snapshot = JSON.parse(snapshotJson) as CommerceAdmissionSnapshot;
|
|
57
87
|
if (
|
|
58
88
|
snapshot.tenantId !== tenantId ||
|
|
59
89
|
snapshot.stage !== stage ||
|
|
@@ -106,3 +136,63 @@ export function admissionMatches(
|
|
|
106
136
|
canonicalJson(sort(event.lines)) === canonicalJson(sort(snapshot.lines))
|
|
107
137
|
);
|
|
108
138
|
}
|
|
139
|
+
|
|
140
|
+
/** The fields of a provider order event that bind it to a final-cart admission. */
|
|
141
|
+
export interface AdmissionBoundEvent {
|
|
142
|
+
command: "observe" | "capture" | "refund" | "cancel";
|
|
143
|
+
eventVersion: number;
|
|
144
|
+
sourceEventId: string;
|
|
145
|
+
source: string;
|
|
146
|
+
sourceOrderId: string;
|
|
147
|
+
placedAt: string;
|
|
148
|
+
lines?: readonly { sku: string; quantity: number; unitMinor: number }[];
|
|
149
|
+
admissionReference?: string;
|
|
150
|
+
providerCartDigest?: string;
|
|
151
|
+
destinationDigest?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const COMMAND_RANK = { observe: 0, capture: 1, refund: 2, cancel: 3 } as const;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The newest event that owns the order's cart binding. A paid or cart snapshot owns its lines,
|
|
158
|
+
* provider cart digest, destination digest and admission reference together; a status-only update
|
|
159
|
+
* keeps those facts but never replaces them.
|
|
160
|
+
*/
|
|
161
|
+
export function currentCartEvent<E extends AdmissionBoundEvent>(events: readonly E[]): E | undefined {
|
|
162
|
+
return [...events]
|
|
163
|
+
.sort(
|
|
164
|
+
(a, b) =>
|
|
165
|
+
b.eventVersion - a.eventVersion ||
|
|
166
|
+
COMMAND_RANK[b.command] - COMMAND_RANK[a.command] ||
|
|
167
|
+
b.sourceEventId.localeCompare(a.sourceEventId),
|
|
168
|
+
)
|
|
169
|
+
.find(
|
|
170
|
+
(event) =>
|
|
171
|
+
event.command === "capture" ||
|
|
172
|
+
event.lines !== undefined ||
|
|
173
|
+
event.admissionReference !== undefined ||
|
|
174
|
+
event.providerCartDigest !== undefined ||
|
|
175
|
+
event.destinationDigest !== undefined,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when this admission is the one the order's paid events were placed against: same cart, same totals, placed before it expired. */
|
|
180
|
+
export function admissionBindsOrder(
|
|
181
|
+
admission: CommerceAdmissionSnapshot,
|
|
182
|
+
events: readonly AdmissionBoundEvent[],
|
|
183
|
+
order: { source: string; sourceOrderId: string; currency: string; totals: MinorTotals },
|
|
184
|
+
): boolean {
|
|
185
|
+
const cart = currentCartEvent(events);
|
|
186
|
+
if (!cart) return false;
|
|
187
|
+
return (
|
|
188
|
+
events.every((event) => event.source === order.source && event.sourceOrderId === order.sourceOrderId) &&
|
|
189
|
+
admissionMatches(admission, {
|
|
190
|
+
currency: order.currency,
|
|
191
|
+
totals: order.totals,
|
|
192
|
+
lines: cart.lines ?? [],
|
|
193
|
+
providerCartDigest: cart.providerCartDigest,
|
|
194
|
+
destinationDigest: cart.destinationDigest,
|
|
195
|
+
}) &&
|
|
196
|
+
Date.parse(cart.placedAt) <= Date.parse(admission.expiresAt)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { type CommerceEvidence, type CommerceStage } from "@tot/public-runtime";
|
|
3
3
|
import type { Queryable } from "../catalog/d1/catalog.js";
|
|
4
4
|
import { foldCommerceEvents, type StoredCommercePayload } from "../orders/data/orderMapper.js";
|
|
5
|
-
import {
|
|
5
|
+
import { admissionBindsOrder, currentCartEvent, readCommerceAdmission } from "../checkout/commerce/commerceAdmission.js";
|
|
6
6
|
import { FulfillmentService } from "./fulfillmentService.js";
|
|
7
7
|
import { fulfillmentDbFromQueryable } from "./store.js";
|
|
8
8
|
const UNKNOWN: CommerceEvidence = { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
|
|
@@ -20,44 +20,13 @@ export async function projectCommerceOrder(
|
|
|
20
20
|
const events = rows.map((row) => (JSON.parse(row.payload) as StoredCommercePayload).source);
|
|
21
21
|
if (!events.length) throw new Error("source-unavailable");
|
|
22
22
|
const snapshot = foldCommerceEvents(events);
|
|
23
|
-
const
|
|
24
|
-
const sorted = [...events].sort(
|
|
25
|
-
(a, b) =>
|
|
26
|
-
b.eventVersion - a.eventVersion ||
|
|
27
|
-
commandRank[b.command] - commandRank[a.command] ||
|
|
28
|
-
b.sourceEventId.localeCompare(a.sourceEventId),
|
|
29
|
-
);
|
|
30
|
-
// A paid/cart snapshot owns all of its binding fields together. A status-only
|
|
31
|
-
// update can retain those immutable facts, but cannot replace any of them.
|
|
32
|
-
const currentCart = sorted.find(
|
|
33
|
-
(event) =>
|
|
34
|
-
event.command === "capture" ||
|
|
35
|
-
event.lines !== undefined ||
|
|
36
|
-
event.admissionReference !== undefined ||
|
|
37
|
-
event.providerCartDigest !== undefined ||
|
|
38
|
-
event.destinationDigest !== undefined,
|
|
39
|
-
);
|
|
23
|
+
const currentCart = currentCartEvent(events);
|
|
40
24
|
const lines = currentCart?.lines ?? [];
|
|
41
25
|
const admission = currentCart?.admissionReference
|
|
42
26
|
? await readCommerceAdmission(db, input.tenantId, input.stage, currentCart.admissionReference)
|
|
43
27
|
: null;
|
|
44
|
-
const sameSourceOrder = events.every(
|
|
45
|
-
(event) => event.source === snapshot.source && event.sourceOrderId === snapshot.sourceOrderId,
|
|
46
|
-
);
|
|
47
28
|
let trusted = false;
|
|
48
|
-
if (
|
|
49
|
-
admission &&
|
|
50
|
-
currentCart &&
|
|
51
|
-
sameSourceOrder &&
|
|
52
|
-
admissionMatches(admission, {
|
|
53
|
-
currency: snapshot.currency,
|
|
54
|
-
totals: snapshot.totals,
|
|
55
|
-
lines,
|
|
56
|
-
providerCartDigest: currentCart.providerCartDigest,
|
|
57
|
-
destinationDigest: currentCart.destinationDigest,
|
|
58
|
-
}) &&
|
|
59
|
-
Date.parse(currentCart.placedAt) <= Date.parse(admission.expiresAt)
|
|
60
|
-
) {
|
|
29
|
+
if (admission && currentCart && admissionBindsOrder(admission, events, snapshot)) {
|
|
61
30
|
if (!db.batchResults) throw new Error("transaction-required");
|
|
62
31
|
const results = await db.batchResults([
|
|
63
32
|
{
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orders workspace actions. Every button in the order detail answers: it opens the part of the
|
|
3
|
+
* order it names, moves to the tab that resolves it, loads the owner-only address, or says why
|
|
4
|
+
* nothing can be done from here. `planOrderAction` decides on the server; `mountOrderActions`
|
|
5
|
+
* carries the plan out in the browser. Neither ever leaves a click without a visible result.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type OrderActionKind = "reveal" | "tab" | "address" | "explain";
|
|
9
|
+
|
|
10
|
+
export interface OrderActionPlan {
|
|
11
|
+
kind: OrderActionKind;
|
|
12
|
+
/** `reveal`: selector of the section inside the order record. `tab`: the admin tab id. */
|
|
13
|
+
target: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OrderActionInput {
|
|
18
|
+
/** The readiness check key, or `blocker` for the order-level Resolve hold / next-step buttons. */
|
|
19
|
+
check: string;
|
|
20
|
+
/** The state the check reports, e.g. `captured`, `UNKNOWN`, `fresh`. */
|
|
21
|
+
state: string;
|
|
22
|
+
/** The order's blocker label when the order is held. */
|
|
23
|
+
blocker?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const CLEAR_STATES = new Set(["fresh", "not-required"]);
|
|
27
|
+
|
|
28
|
+
function evidenceMessage(label: string, state: string): string {
|
|
29
|
+
if (CLEAR_STATES.has(state)) return `${label} evidence is ${state}. Activity and evidence below shows how this order got here.`;
|
|
30
|
+
return `${label} evidence is ${state}, so this order stays held. Evidence is recorded when the shopper passes final cart; an order whose final cart is not on record cannot be cleared from admin.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function planOrderAction(input: OrderActionInput): OrderActionPlan {
|
|
34
|
+
switch (input.check) {
|
|
35
|
+
case "payment_capture":
|
|
36
|
+
return {
|
|
37
|
+
kind: "explain",
|
|
38
|
+
target: "",
|
|
39
|
+
message:
|
|
40
|
+
input.state === "captured" || input.state === "Captured"
|
|
41
|
+
? "Payment is captured."
|
|
42
|
+
: "Payment is captured by the checkout provider and recorded here when it confirms. Admin cannot capture it; check the transaction at the provider.",
|
|
43
|
+
};
|
|
44
|
+
case "tax_and_fees":
|
|
45
|
+
return { kind: "reveal", target: "[data-orders-totals]", message: "Items and totals are open below." };
|
|
46
|
+
case "identity_and_age":
|
|
47
|
+
return { kind: "reveal", target: "[data-orders-evidence]", message: evidenceMessage("Identity and age", input.state) };
|
|
48
|
+
case "risk":
|
|
49
|
+
return { kind: "reveal", target: "[data-orders-evidence]", message: evidenceMessage("Risk", input.state) };
|
|
50
|
+
case "address":
|
|
51
|
+
return { kind: "address", target: "[data-orders-address]", message: "" };
|
|
52
|
+
case "stock":
|
|
53
|
+
return {
|
|
54
|
+
kind: "explain",
|
|
55
|
+
target: "",
|
|
56
|
+
message:
|
|
57
|
+
input.state === "confirmed"
|
|
58
|
+
? "Stock is confirmed for this order."
|
|
59
|
+
: `Stock is ${input.state} for this order. Stock is confirmed when the final-cart reservation matches the paid order.`,
|
|
60
|
+
};
|
|
61
|
+
case "fulfillment":
|
|
62
|
+
return { kind: "tab", target: "fulfillment", message: "Fulfillment holds are worked in the Fulfillment tab." };
|
|
63
|
+
case "blocker":
|
|
64
|
+
return blockerPlan(input);
|
|
65
|
+
default:
|
|
66
|
+
return { kind: "explain", target: "", message: "Nothing can be done for this check from here." };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function blockerPlan(input: OrderActionInput): OrderActionPlan {
|
|
71
|
+
if (!input.blocker) {
|
|
72
|
+
return { kind: "explain", target: "", message: "This order has no hold." };
|
|
73
|
+
}
|
|
74
|
+
if (input.blocker === "Payment capture") return planOrderAction({ check: "payment_capture", state: input.state });
|
|
75
|
+
if (input.blocker === "Evidence review" || input.blocker === "Age verification hold" || input.blocker === "Verification unknown") {
|
|
76
|
+
return planOrderAction({ check: "identity_and_age", state: input.state });
|
|
77
|
+
}
|
|
78
|
+
return { kind: "tab", target: "fulfillment", message: `${input.blocker}: holds are worked in the Fulfillment tab.` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Owner address response, as stored. Only these fields are shown, in this order. */
|
|
82
|
+
const ADDRESS_FIELDS = ["name", "line1", "line2", "city", "region", "postalCode", "country"] as const;
|
|
83
|
+
|
|
84
|
+
export function addressLines(address: unknown): string[] {
|
|
85
|
+
if (address === null || typeof address !== "object" || Array.isArray(address)) return [];
|
|
86
|
+
const record = address as Record<string, unknown>;
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
for (const key of ADDRESS_FIELDS) {
|
|
89
|
+
const value = record[key];
|
|
90
|
+
if (typeof value === "string" && value.trim() !== "") lines.push(value.trim());
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const ADDRESS_NONE =
|
|
96
|
+
"No ship-to address is stored for this order. The checkout confirmation carries none and the order ledger keeps no customer data.";
|
|
97
|
+
export const ADDRESS_DENIED = "Only the store owner can view a ship-to address.";
|
|
98
|
+
export const ADDRESS_FAILED = "The address could not be loaded. Try again.";
|
|
99
|
+
|
|
100
|
+
export interface OrderActionDeps {
|
|
101
|
+
fetch: typeof fetch;
|
|
102
|
+
/** The fulfillment API root for the selected store, or null when this page cannot reach it. */
|
|
103
|
+
api: string | null;
|
|
104
|
+
navigate: (tab: string) => boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function show(note: HTMLElement | null, lines: readonly string[]): void {
|
|
108
|
+
if (!note) return;
|
|
109
|
+
note.replaceChildren(...lines.map((line) => Object.assign(document.createElement("span"), { textContent: line })));
|
|
110
|
+
note.hidden = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadAddress(orderId: string, note: HTMLElement | null, deps: OrderActionDeps): Promise<void> {
|
|
114
|
+
if (!deps.api) {
|
|
115
|
+
show(note, [ADDRESS_FAILED]);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
show(note, ["Loading address…"]);
|
|
119
|
+
try {
|
|
120
|
+
const response = await deps.fetch(`${deps.api}/orders/${encodeURIComponent(orderId)}/address`, {
|
|
121
|
+
credentials: "same-origin",
|
|
122
|
+
cache: "no-store",
|
|
123
|
+
});
|
|
124
|
+
if (response.status === 404) return show(note, [ADDRESS_NONE]);
|
|
125
|
+
if (response.status === 401 || response.status === 403) return show(note, [ADDRESS_DENIED]);
|
|
126
|
+
if (!response.ok) return show(note, [ADDRESS_FAILED]);
|
|
127
|
+
const lines = addressLines(await response.json());
|
|
128
|
+
show(note, lines.length ? lines : [ADDRESS_NONE]);
|
|
129
|
+
} catch {
|
|
130
|
+
show(note, [ADDRESS_FAILED]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wire every `[data-orders-action]` button under `root`. The button carries its plan as
|
|
136
|
+
* `data-orders-action-kind|target|message`; its note is the nearest `[data-orders-action-note]` in
|
|
137
|
+
* the same record. Order-level buttons (`data-orders-resolve-hold`) act through the selected record's
|
|
138
|
+
* `[data-orders-blocker-action]`.
|
|
139
|
+
*/
|
|
140
|
+
export function mountOrderActions(root: HTMLElement, deps: OrderActionDeps): void {
|
|
141
|
+
const run = (button: HTMLElement, record: HTMLElement): void => {
|
|
142
|
+
const kind = button.dataset.ordersActionKind as OrderActionKind | undefined;
|
|
143
|
+
const target = button.dataset.ordersActionTarget ?? "";
|
|
144
|
+
const message = button.dataset.ordersActionMessage ?? "";
|
|
145
|
+
const orderId = record.dataset.ordersDetailRecord ?? "";
|
|
146
|
+
const scope = button.closest<HTMLElement>("[data-orders-action-scope]");
|
|
147
|
+
const note = scope
|
|
148
|
+
? scope.querySelector<HTMLElement>("[data-orders-action-note]")
|
|
149
|
+
: record.querySelector<HTMLElement>("[data-orders-record-note]");
|
|
150
|
+
if (kind === "reveal") {
|
|
151
|
+
const section = record.querySelector<HTMLDetailsElement>(target);
|
|
152
|
+
if (section) {
|
|
153
|
+
section.open = true;
|
|
154
|
+
section.scrollIntoView?.({ block: "nearest" });
|
|
155
|
+
}
|
|
156
|
+
show(note, [message]);
|
|
157
|
+
} else if (kind === "tab") {
|
|
158
|
+
show(note, [deps.navigate(target) ? message : `${message} The ${target} tab is not available to this account.`]);
|
|
159
|
+
} else if (kind === "address") {
|
|
160
|
+
void loadAddress(orderId, record.querySelector<HTMLElement>(target) ?? note, deps);
|
|
161
|
+
} else {
|
|
162
|
+
show(note, [message || "Nothing can be done from here."]);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
root.addEventListener("click", (event) => {
|
|
167
|
+
const origin = event.target instanceof Element ? event.target : null;
|
|
168
|
+
const button = origin?.closest<HTMLElement>("[data-orders-action]");
|
|
169
|
+
if (button && root.contains(button)) {
|
|
170
|
+
const record = button.closest<HTMLElement>("[data-orders-detail-record]");
|
|
171
|
+
if (record) run(button, record);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const orderLevel = origin?.closest<HTMLElement>("[data-orders-resolve-hold], [data-orders-next-step]");
|
|
175
|
+
if (!orderLevel || !root.contains(orderLevel)) return;
|
|
176
|
+
const selected = orderLevel.dataset.ordersNextStep ?? root.querySelector<HTMLElement>("[data-orders-detail-root]")?.dataset.adminSelectedId ?? "";
|
|
177
|
+
const record = Array.from(root.querySelectorAll<HTMLElement>("[data-orders-detail-record]")).find(
|
|
178
|
+
(item) => item.dataset.ordersDetailRecord === selected,
|
|
179
|
+
);
|
|
180
|
+
const action = record?.querySelector<HTMLElement>("[data-orders-blocker-action]");
|
|
181
|
+
if (!record || !action) {
|
|
182
|
+
show(root.querySelector<HTMLElement>("[data-orders-root-note]"), ["Select an order first."]);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
record.hidden = false;
|
|
186
|
+
record.scrollIntoView?.({ block: "nearest" });
|
|
187
|
+
run(action, record);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Switch the admin shell to a tab by id; false when the tab is not in this page. */
|
|
192
|
+
export function navigateAdminTab(tab: string): boolean {
|
|
193
|
+
if (!document.getElementById(tab)) return false;
|
|
194
|
+
window.location.hash = `#${tab}`;
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
CheckoutOrderListResponse,
|
|
20
20
|
CheckoutOrderPayment,
|
|
21
21
|
CheckoutOrderSummary,
|
|
22
|
+
CommerceEvidence,
|
|
22
23
|
CommerceScope,
|
|
23
24
|
CommerceStage,
|
|
24
25
|
CustomizationEnv,
|
|
@@ -193,9 +194,10 @@ interface LedgerOrder {
|
|
|
193
194
|
function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
194
195
|
const placedAt = isoInstant(entry.placedAt);
|
|
195
196
|
const snapshot = entry.events.length > 0 ? foldCommerceEvents(entry.events) : null;
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const evidence
|
|
197
|
+
// The order row is the state of record: provider events and fulfillment commands both write it.
|
|
198
|
+
const { payment, fulfillment } = entry;
|
|
199
|
+
const evidence: CommerceEvidence =
|
|
200
|
+
entry.admitted?.evidence ?? snapshot?.verificationDetail ?? { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
|
|
199
201
|
const totals = snapshot?.totals ?? { subtotalMinor: entry.amountMinor, shippingMinor: 0, taxMinor: 0, totalMinor: entry.amountMinor };
|
|
200
202
|
const lines =
|
|
201
203
|
[...entry.events].sort((a, b) => b.eventVersion - a.eventVersion).find((event) => event.lines?.length)?.lines ?? [];
|
|
@@ -217,10 +219,10 @@ function ledgerOrder(entry: CommerceOrderBookEntry): LedgerOrder {
|
|
|
217
219
|
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
218
220
|
const itemsSummary =
|
|
219
221
|
lines.length === 0 ? "Items not recorded" : lines.length === 1 ? lines[0]!.sku : `${lines[0]!.sku} +${lines.length - 1} more`;
|
|
220
|
-
const verification: Record<string, string> = {};
|
|
222
|
+
const verification: Record<string, string> = { stockState: entry.stock };
|
|
221
223
|
for (const key of ["verification", "tax", "risk", "stock"] as const) {
|
|
222
224
|
verification[key] = evidence[key];
|
|
223
|
-
verification[`${key}Source`] = "commerce-ledger";
|
|
225
|
+
verification[`${key}Source`] = entry.admitted?.provenance[key].source ?? "commerce-ledger";
|
|
224
226
|
verification[`${key}AsOf`] = placedAt;
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
paymentIsCaptured,
|
|
35
35
|
publicCommerceEvent,
|
|
36
36
|
readOwnerAddress,
|
|
37
|
+
STOCK_STATES,
|
|
37
38
|
sumMinor,
|
|
38
39
|
totalsMatch,
|
|
39
40
|
} from "@tot/public-runtime";
|
|
@@ -209,6 +210,21 @@ function readSourcedEvidence(
|
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/** Stock allocation state the order source recorded, never inferred from stock evidence. */
|
|
214
|
+
function readStockState(verification: CheckoutOrderVerification | null): SourcedState<StockState> {
|
|
215
|
+
const state = verification?.stockState;
|
|
216
|
+
if (typeof state !== "string" || !(STOCK_STATES as readonly string[]).includes(state)) {
|
|
217
|
+
return { state: "UNKNOWN", source: "absent", asOf: null };
|
|
218
|
+
}
|
|
219
|
+
const source = verification?.stockSource;
|
|
220
|
+
const asOf = verification?.stockAsOf;
|
|
221
|
+
return {
|
|
222
|
+
state: state as StockState,
|
|
223
|
+
source: typeof source === "string" && source.length > 0 ? source : "absent",
|
|
224
|
+
asOf: typeof asOf === "string" ? asOf : null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
212
228
|
export function readinessColumns(order: ScrappyOrderRecord): OrderReadinessColumn[] {
|
|
213
229
|
return [
|
|
214
230
|
{ key: "payment", state: order.payment.state, source: order.payment.source, asOf: order.payment.asOf },
|
|
@@ -279,7 +295,7 @@ function checkoutRecord(order: CheckoutOrderSummary, scope: CommerceScope): Scra
|
|
|
279
295
|
tax: readSourcedEvidence(order.verification, "tax"),
|
|
280
296
|
verification: readSourcedEvidence(order.verification, "verification"),
|
|
281
297
|
risk: readSourcedEvidence(order.verification, "risk"),
|
|
282
|
-
stock:
|
|
298
|
+
stock: readStockState(order.verification),
|
|
283
299
|
stockEvidence: readSourcedEvidence(order.verification, "stock"),
|
|
284
300
|
fulfillment: {
|
|
285
301
|
state: fulfillmentFromCheckout(order.fulfillment),
|
|
@@ -17,9 +17,12 @@ import {
|
|
|
17
17
|
idempotencyKey,
|
|
18
18
|
paginateOrders,
|
|
19
19
|
sha256Hex,
|
|
20
|
+
type CommerceEvidence,
|
|
21
|
+
type CommerceEvidenceProvenance,
|
|
20
22
|
type CommerceStage,
|
|
21
23
|
type PaymentState,
|
|
22
24
|
} from "@tot/public-runtime";
|
|
25
|
+
import { admissionBindsOrder, currentCartEvent, readCommerceAdmissions } from "../../checkout/commerce/commerceAdmission.js";
|
|
23
26
|
import {
|
|
24
27
|
foldCommerceEvents,
|
|
25
28
|
packCommercePayload,
|
|
@@ -230,8 +233,15 @@ export interface CommerceOrderBookEntry {
|
|
|
230
233
|
currency: string;
|
|
231
234
|
payment: PaymentState;
|
|
232
235
|
fulfillment: CommerceSnapshot["fulfillment"];
|
|
236
|
+
stock: CommerceSnapshot["stock"];
|
|
233
237
|
amountMinor: number;
|
|
234
238
|
events: CommerceSourceEvent[];
|
|
239
|
+
/**
|
|
240
|
+
* The final-cart evidence this order was placed against, present only when the admission the paid
|
|
241
|
+
* events name is the same cart, totals and destination and had not expired at placement. The paid
|
|
242
|
+
* events carry no evidence of their own.
|
|
243
|
+
*/
|
|
244
|
+
admitted: { evidence: CommerceEvidence; provenance: CommerceEvidenceProvenance } | null;
|
|
235
245
|
}
|
|
236
246
|
|
|
237
247
|
export interface CommerceAggregate {
|
|
@@ -504,16 +514,33 @@ export class ScrappyOrderLedger {
|
|
|
504
514
|
list.push(parsed.source);
|
|
505
515
|
events.set(row.order_id, list);
|
|
506
516
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
+
const references = new Set<string>();
|
|
518
|
+
for (const list of events.values()) {
|
|
519
|
+
const reference = currentCartEvent(list)?.admissionReference;
|
|
520
|
+
if (reference) references.add(reference);
|
|
521
|
+
}
|
|
522
|
+
const admissions = await readCommerceAdmissions(this.db, tenantId, stage, references);
|
|
523
|
+
return rows.map((row) => {
|
|
524
|
+
const orderEvents = events.get(row.order_id) ?? [];
|
|
525
|
+
const reference = currentCartEvent(orderEvents)?.admissionReference;
|
|
526
|
+
const admission = reference ? admissions.get(reference) : undefined;
|
|
527
|
+
const bound =
|
|
528
|
+
admission !== undefined &&
|
|
529
|
+
orderEvents.length > 0 &&
|
|
530
|
+
admissionBindsOrder(admission, orderEvents, foldCommerceEvents(orderEvents));
|
|
531
|
+
return {
|
|
532
|
+
orderId: row.order_id,
|
|
533
|
+
source: row.source,
|
|
534
|
+
placedAt: row.placed_at,
|
|
535
|
+
currency: row.currency,
|
|
536
|
+
payment: row.payment_state,
|
|
537
|
+
fulfillment: row.fulfillment_state,
|
|
538
|
+
stock: row.stock_state,
|
|
539
|
+
amountMinor: row.amount_minor,
|
|
540
|
+
events: orderEvents,
|
|
541
|
+
admitted: bound ? { evidence: admission.evidence, provenance: admission.provenance } : null,
|
|
542
|
+
};
|
|
543
|
+
});
|
|
517
544
|
}
|
|
518
545
|
|
|
519
546
|
/** Reset one outbox row for this tenant and stage. Does not write another order. */
|
|
@@ -169,9 +169,18 @@ export async function sitemapIndexResponse(context: APIContext): Promise<Respons
|
|
|
169
169
|
return new Response(renderSitemapIndexV1(context.locals.canonicalBase, children), { headers: XML_HEADERS });
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* The child path a request names: the last segment of the URL path. Read from the URL, never from
|
|
174
|
+
* `context.params`: a path-routed request (`/<domain>/sitemap-pages-1.xml`) reaches this endpoint
|
|
175
|
+
* through the middleware's rewrite, which leaves the catch-all's params on the context.
|
|
176
|
+
*/
|
|
177
|
+
export function sitemapChildPathOf(context: APIContext): string {
|
|
178
|
+
return `/${context.url.pathname.split("/").pop() ?? ""}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
172
181
|
/** `/sitemap-<group>-<page>.xml`: one child's `<urlset>`, or 404 when the index names no such child. */
|
|
173
|
-
export async function sitemapChildResponse(context: APIContext
|
|
174
|
-
const named = parseSitemapChildPathV1(
|
|
182
|
+
export async function sitemapChildResponse(context: APIContext): Promise<Response> {
|
|
183
|
+
const named = parseSitemapChildPathV1(sitemapChildPathOf(context));
|
|
175
184
|
if (!named) return new Response("Not found", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
176
185
|
const children = await tenantSitemapChildren(context.locals);
|
|
177
186
|
const child = children.find((c) => c.group === named.group && c.page === named.page);
|
|
@@ -6,5 +6,5 @@ import type { APIContext } from "astro";
|
|
|
6
6
|
import { sitemapChildResponse } from "./_sitemap.js";
|
|
7
7
|
|
|
8
8
|
export function GET(context: APIContext): Promise<Response> {
|
|
9
|
-
return sitemapChildResponse(context
|
|
9
|
+
return sitemapChildResponse(context);
|
|
10
10
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.8",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
19
19
|
import { dirname, join } from "node:path";
|
|
20
|
+
import { parseSitemapChildPathV1 } from "./sitemap-index.mjs";
|
|
20
21
|
|
|
21
22
|
/** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
|
|
22
23
|
export const SPACES_DIR = "spaces";
|
|
@@ -26,7 +27,9 @@ export const TOPOLOGY_VERSION = 1;
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
|
|
29
|
-
* the ownership parity test holds the two equal).
|
|
30
|
+
* the ownership parity test holds the two equal). Child sitemaps (`/sitemap-<group>-<page>.xml`) are
|
|
31
|
+
* platform-owned too, recognized by `parseSitemapChildPathV1` (a byte-identical copy of
|
|
32
|
+
* packages/public-runtime/src/sitemap-index.mjs, pinned by test/sitemap-index-copy.test.mjs).
|
|
30
33
|
*/
|
|
31
34
|
export const PLATFORM_PATHS = Object.freeze([
|
|
32
35
|
"/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
|
|
@@ -167,7 +170,9 @@ export function owningSpace(topology, urlPath) {
|
|
|
167
170
|
const path = normalizeRequestPath(urlPath);
|
|
168
171
|
if (path === null) return null;
|
|
169
172
|
const folded = path.toLowerCase();
|
|
170
|
-
if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))
|
|
173
|
+
if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded)) || parseSitemapChildPathV1(folded) !== null) {
|
|
174
|
+
return { space: null, via: "platform" };
|
|
175
|
+
}
|
|
171
176
|
/** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
|
|
172
177
|
let best = null;
|
|
173
178
|
for (const space of topology.spaces) {
|
|
@@ -44,8 +44,9 @@ export function sitemapChildPathV1(group, page) {
|
|
|
44
44
|
export function parseSitemapChildPathV1(path) {
|
|
45
45
|
if (typeof path !== "string") return null;
|
|
46
46
|
const match = CHILD_PATH.exec(path);
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const group = match?.[1];
|
|
48
|
+
if (!match || group === undefined || !SITEMAP_GROUPS_V1.includes(group)) return null;
|
|
49
|
+
return { group, page: Number(match[2]) };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** True for `/sitemap.xml` and for every well-formed child sitemap path. @param {string} path */
|
|
@@ -139,7 +140,7 @@ export function isSitemapIndexXmlV1(xml) {
|
|
|
139
140
|
*/
|
|
140
141
|
export function sitemapIndexLocsV1(xml) {
|
|
141
142
|
if (!isSitemapIndexXmlV1(xml)) return [];
|
|
142
|
-
return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => match[1].trim());
|
|
143
|
+
return [...xml.matchAll(/<sitemap>\s*<loc>([^<]*)<\/loc>/g)].map((match) => (match[1] ?? "").trim());
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
/**
|