@tokenoftrust/storefront-runner 2.4.53 → 2.4.55
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/apps/orders/orderForwardReceiver.ts +203 -1
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +321 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +331 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +1654 -0
- package/apps/storefront/src/lib/fulfillment/readiness.ts +150 -0
- package/apps/storefront/src/lib/fulfillment/store.ts +109 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +110 -1
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +70 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +29 -2
- package/apps/storefront/src/pages/api/admin/fulfillment/batches.ts +59 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +32 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id].ts +117 -0
- package/package.json +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release and hand-off gates for whole-order fulfillment.
|
|
3
|
+
*
|
|
4
|
+
* Evidence, payment, stock, refund, and cancel are rechecked from stored source
|
|
5
|
+
* truth. Compliance `fresh` is not evidence. A carrier account is tenant
|
|
6
|
+
* configuration: its absence is not a hold, and this module does not select a vendor.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
decideCommerceCommand,
|
|
10
|
+
paymentIsCaptured,
|
|
11
|
+
type CommerceEvidence,
|
|
12
|
+
type ComplianceRuleProfile,
|
|
13
|
+
type EvidenceState,
|
|
14
|
+
type FulfillmentState,
|
|
15
|
+
type LabelState,
|
|
16
|
+
type PaymentState,
|
|
17
|
+
type ProviderOutcome,
|
|
18
|
+
type StockState,
|
|
19
|
+
} from "@tot/public-runtime";
|
|
20
|
+
import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
|
|
21
|
+
|
|
22
|
+
export const RELEASE_HOLDS = [
|
|
23
|
+
"evidence-unknown",
|
|
24
|
+
"evidence-stale",
|
|
25
|
+
"evidence-unreadable",
|
|
26
|
+
"evidence-absent",
|
|
27
|
+
"operator-hold",
|
|
28
|
+
"payment-pending",
|
|
29
|
+
"payment-unknown",
|
|
30
|
+
"payment-not-captured",
|
|
31
|
+
"refund-pending",
|
|
32
|
+
"refunded",
|
|
33
|
+
"refund-unknown",
|
|
34
|
+
"cancel-pending",
|
|
35
|
+
"cancelled",
|
|
36
|
+
"stock-unknown",
|
|
37
|
+
"stock-unconfirmed",
|
|
38
|
+
"fulfillment-unknown",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type ReleaseHold = (typeof RELEASE_HOLDS)[number];
|
|
42
|
+
|
|
43
|
+
export interface CarrierPolicy {
|
|
44
|
+
adultSignatureRequired: boolean;
|
|
45
|
+
prohibitedCarriers: readonly string[];
|
|
46
|
+
/** Null when the tenant has not configured an account. Absence is not a hold. */
|
|
47
|
+
carrierAccountId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ReadinessInput {
|
|
51
|
+
payment: PaymentState;
|
|
52
|
+
stock: StockState;
|
|
53
|
+
fulfillment: FulfillmentState;
|
|
54
|
+
label: LabelState;
|
|
55
|
+
evidence: CommerceEvidence;
|
|
56
|
+
operatorHold: boolean;
|
|
57
|
+
cancelPending: boolean;
|
|
58
|
+
providerOutcome: ProviderOutcome | null;
|
|
59
|
+
adultSignature: "accepted" | "refused" | "absent";
|
|
60
|
+
adultSignatureRequired: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EVIDENCE_HOLDS: Record<EvidenceState, ReleaseHold | null> = {
|
|
64
|
+
fresh: null,
|
|
65
|
+
UNKNOWN: "evidence-unknown",
|
|
66
|
+
stale: "evidence-stale",
|
|
67
|
+
unreadable: "evidence-unreadable",
|
|
68
|
+
absent: "evidence-absent",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function add(holds: ReleaseHold[], hold: ReleaseHold | null): void {
|
|
72
|
+
if (hold && !holds.includes(hold)) holds.push(hold);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Holds that forbid creating a shipment intent. Compliance is not an input. */
|
|
76
|
+
export function releaseHolds(input: ReadinessInput): ReleaseHold[] {
|
|
77
|
+
const holds: ReleaseHold[] = [];
|
|
78
|
+
for (const field of ["verification", "tax", "risk", "stock"] as const) {
|
|
79
|
+
const value = input.evidence[field];
|
|
80
|
+
add(holds, value in EVIDENCE_HOLDS ? EVIDENCE_HOLDS[value as EvidenceState] : "evidence-unreadable");
|
|
81
|
+
}
|
|
82
|
+
if (input.operatorHold) add(holds, "operator-hold");
|
|
83
|
+
if (input.cancelPending) add(holds, "cancel-pending");
|
|
84
|
+
if (input.fulfillment === "cancelled") add(holds, "cancelled");
|
|
85
|
+
if (input.fulfillment === "UNKNOWN") add(holds, "fulfillment-unknown");
|
|
86
|
+
if (input.payment === "pending" || input.payment === "authorized") add(holds, "payment-pending");
|
|
87
|
+
else if (input.payment === "UNKNOWN" || input.payment === "capture_unknown") add(holds, "payment-unknown");
|
|
88
|
+
else if (input.payment === "refund_requested") add(holds, "refund-pending");
|
|
89
|
+
else if (input.payment === "refunded") add(holds, "refunded");
|
|
90
|
+
else if (input.payment === "refund_unknown") add(holds, "refund-unknown");
|
|
91
|
+
else if (input.payment === "voided") add(holds, "cancelled");
|
|
92
|
+
else if (!paymentIsCaptured(input.payment)) add(holds, "payment-not-captured");
|
|
93
|
+
if (input.stock === "UNKNOWN") add(holds, "stock-unknown");
|
|
94
|
+
else if (input.stock !== "confirmed") add(holds, "stock-unconfirmed");
|
|
95
|
+
return holds;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function canRelease(input: ReadinessInput): boolean {
|
|
99
|
+
return input.fulfillment === "ready" && releaseHolds(input).length === 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function handoffRefusal(input: ReadinessInput): ReleaseHold | "adult-signature" | "label" | "reconcile" | "fulfillment" | "evidence" | "payment" | "stock" | "reuse" | null {
|
|
103
|
+
const holds = releaseHolds(input);
|
|
104
|
+
if (holds.length > 0) return holds[0] ?? "fulfillment";
|
|
105
|
+
const decision = decideCommerceCommand({
|
|
106
|
+
command: "hand-off",
|
|
107
|
+
payment: input.payment,
|
|
108
|
+
stock: input.stock,
|
|
109
|
+
fulfillment: input.fulfillment,
|
|
110
|
+
label: input.label,
|
|
111
|
+
evidence: input.evidence,
|
|
112
|
+
providerOutcome: input.providerOutcome,
|
|
113
|
+
});
|
|
114
|
+
if (!decision.admit) return decision.reason === "admitted" ? "fulfillment" : decision.reason;
|
|
115
|
+
if (input.adultSignatureRequired && input.adultSignature !== "accepted") return "adult-signature";
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function carrierPolicyForTenant(tenant: {
|
|
120
|
+
tenant_id: string;
|
|
121
|
+
compliance?: { ruleProfile?: string };
|
|
122
|
+
carrierAccountId?: unknown;
|
|
123
|
+
}): CarrierPolicy {
|
|
124
|
+
const profile = tenant.compliance?.ruleProfile;
|
|
125
|
+
const build =
|
|
126
|
+
profile && Object.hasOwn(COMPLIANCE_RULE_PROFILES, profile)
|
|
127
|
+
? COMPLIANCE_RULE_PROFILES[profile as ComplianceRuleProfile]
|
|
128
|
+
: undefined;
|
|
129
|
+
const ruleset = build ? build(tenant.tenant_id) : null;
|
|
130
|
+
const raw = tenant.carrierAccountId;
|
|
131
|
+
const carrierAccountId = typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
|
|
132
|
+
const enabled = ruleset?.pactAct.enabled === true;
|
|
133
|
+
return {
|
|
134
|
+
adultSignatureRequired: enabled && ruleset.pactAct.adultSignatureRequired === true,
|
|
135
|
+
prohibitedCarriers: enabled ? [...ruleset.pactAct.prohibitedCarriers] : [],
|
|
136
|
+
carrierAccountId,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A supplied carrier is never a selection. USPS is refused when the policy prohibits it. */
|
|
141
|
+
export function carrierSelectionRefusal(
|
|
142
|
+
policy: CarrierPolicy,
|
|
143
|
+
carrier: string | undefined,
|
|
144
|
+
): "carrier-prohibited" | "vendor-not-selected" | null {
|
|
145
|
+
if (carrier === undefined) return null;
|
|
146
|
+
const normalized = carrier.trim().toUpperCase();
|
|
147
|
+
if (normalized === "") return "vendor-not-selected";
|
|
148
|
+
if (policy.prohibitedCarriers.some((item) => item.toUpperCase() === normalized)) return "carrier-prohibited";
|
|
149
|
+
return "vendor-not-selected";
|
|
150
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-scoped SQL seam for scrappy commerce tables.
|
|
3
|
+
*
|
|
4
|
+
* Batch phase, holds, packing slips, and label intents are rows in
|
|
5
|
+
* `scrappy_shipment_audit` (append-only). The commerce migration has no batch
|
|
6
|
+
* table; this store does not create one.
|
|
7
|
+
*/
|
|
8
|
+
import type { D1Like } from "../d1/catalog.js";
|
|
9
|
+
|
|
10
|
+
export interface SqlStatement {
|
|
11
|
+
sql: string;
|
|
12
|
+
params: readonly unknown[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FulfillmentDatabase {
|
|
16
|
+
get<T extends Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T | undefined>;
|
|
17
|
+
all<T extends Record<string, unknown>>(sql: string, params?: readonly unknown[]): Promise<T[]>;
|
|
18
|
+
/** One transaction. A unique-constraint loss returns zeros so the caller can re-read. */
|
|
19
|
+
batch(statements: readonly SqlStatement[]): Promise<number[]>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SqliteStatement {
|
|
23
|
+
all(...params: unknown[]): Array<Record<string, unknown>>;
|
|
24
|
+
get(...params: unknown[]): Record<string, unknown> | undefined;
|
|
25
|
+
run(...params: unknown[]): { changes?: number | bigint };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SqliteDatabase {
|
|
29
|
+
prepare(sql: string): SqliteStatement;
|
|
30
|
+
exec(sql: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function changesOf(result: { changes?: number | bigint } | undefined): number {
|
|
34
|
+
const changes = result?.changes ?? 0;
|
|
35
|
+
return typeof changes === "bigint" ? Number(changes) : changes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lostRace(error: unknown): boolean {
|
|
39
|
+
return error instanceof Error && error.message.includes("UNIQUE constraint failed");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function fulfillmentDbFromSqlite(db: SqliteDatabase): FulfillmentDatabase {
|
|
43
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
44
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
45
|
+
const enqueue = <T>(fn: () => T): Promise<T> => {
|
|
46
|
+
const run = chain.then(
|
|
47
|
+
() => fn(),
|
|
48
|
+
() => fn(),
|
|
49
|
+
);
|
|
50
|
+
chain = run.then(
|
|
51
|
+
() => undefined,
|
|
52
|
+
() => undefined,
|
|
53
|
+
);
|
|
54
|
+
return run;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
get: (sql, params = []) => enqueue(() => db.prepare(sql).get(...params) as never),
|
|
58
|
+
all: (sql, params = []) => enqueue(() => db.prepare(sql).all(...params) as never),
|
|
59
|
+
batch: (statements) =>
|
|
60
|
+
enqueue(() => {
|
|
61
|
+
if (statements.length === 0) return [];
|
|
62
|
+
db.exec("BEGIN IMMEDIATE");
|
|
63
|
+
const changes: number[] = [];
|
|
64
|
+
try {
|
|
65
|
+
for (const statement of statements) {
|
|
66
|
+
changes.push(changesOf(db.prepare(statement.sql).run(...statement.params)));
|
|
67
|
+
}
|
|
68
|
+
db.exec("COMMIT");
|
|
69
|
+
return changes;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
try {
|
|
72
|
+
db.exec("ROLLBACK");
|
|
73
|
+
} catch {
|
|
74
|
+
/* the transaction is already closed */
|
|
75
|
+
}
|
|
76
|
+
if (lostRace(error)) return statements.map(() => 0);
|
|
77
|
+
throw new Error("storage", { cause: error });
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function fulfillmentDbFromD1(db: D1Like): FulfillmentDatabase {
|
|
84
|
+
return {
|
|
85
|
+
async get(sql, params = []) {
|
|
86
|
+
const row = await db.prepare(sql).bind(...params).first();
|
|
87
|
+
return (row ?? undefined) as never;
|
|
88
|
+
},
|
|
89
|
+
async all(sql, params = []) {
|
|
90
|
+
const result = await db.prepare(sql).bind(...params).all();
|
|
91
|
+
return (result.results ?? []) as never;
|
|
92
|
+
},
|
|
93
|
+
async batch(statements) {
|
|
94
|
+
if (statements.length === 0) return [];
|
|
95
|
+
if (!db.batch) throw new Error("storage");
|
|
96
|
+
try {
|
|
97
|
+
const results = await db.batch(statements.map((statement) => db.prepare(statement.sql).bind(...statement.params)));
|
|
98
|
+
return results.map((result) => {
|
|
99
|
+
if (!result || typeof result !== "object") return 0;
|
|
100
|
+
const meta = Reflect.get(result, "meta") as { changes?: number } | undefined;
|
|
101
|
+
return Number(meta?.changes ?? 0);
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (lostRace(error)) return statements.map(() => 0);
|
|
105
|
+
throw new Error("storage", { cause: error });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
* public target can't 302 its way to an internal one; either case never
|
|
23
23
|
* reads as `delivered`.
|
|
24
24
|
*/
|
|
25
|
-
import { WebhookDeliveryStore, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
|
|
25
|
+
import { WebhookDeliveryStore, ScrappyCommerceOutbox, type CommerceOutboxDue, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
|
|
26
26
|
import type { AppInstall } from "../apps/registryService.js";
|
|
27
27
|
import { buildCloudEventEnvelope } from "./events.js";
|
|
28
28
|
import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
|
|
29
29
|
import { validateWebhookEndpoint } from "./endpointGuard.js";
|
|
30
|
+
import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
|
|
31
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
30
32
|
|
|
31
33
|
/** Bounded per-attempt timeout — a hung receiver must not hold a dispatcher slot open indefinitely. */
|
|
32
34
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -277,3 +279,110 @@ async function postWithTimeout(
|
|
|
277
279
|
clearTimeout(timer);
|
|
278
280
|
}
|
|
279
281
|
}
|
|
282
|
+
|
|
283
|
+
const COMMERCE_PRIVATE_KEYS = new Set([
|
|
284
|
+
"customer",
|
|
285
|
+
"customerEmail",
|
|
286
|
+
"customerName",
|
|
287
|
+
"email",
|
|
288
|
+
"phone",
|
|
289
|
+
"line1",
|
|
290
|
+
"line2",
|
|
291
|
+
"postalCode",
|
|
292
|
+
"shippingAddress",
|
|
293
|
+
"billingAddress",
|
|
294
|
+
"address",
|
|
295
|
+
"firstName",
|
|
296
|
+
"lastName",
|
|
297
|
+
"name",
|
|
298
|
+
]);
|
|
299
|
+
|
|
300
|
+
function commercePayloadHasPrivateIdentity(value: unknown): boolean {
|
|
301
|
+
if (value === null || typeof value !== "object") return false;
|
|
302
|
+
if (Array.isArray(value)) return value.some((item) => commercePayloadHasPrivateIdentity(item));
|
|
303
|
+
return Object.entries(value as Record<string, unknown>).some(
|
|
304
|
+
([key, child]) => COMMERCE_PRIVATE_KEYS.has(key) || commercePayloadHasPrivateIdentity(child),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function publicCommercePayload(payload: string): Record<string, unknown> | null {
|
|
309
|
+
let parsed: unknown;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(payload);
|
|
312
|
+
} catch {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
if (commercePayloadHasPrivateIdentity(parsed)) return null;
|
|
316
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
317
|
+
const body = parsed as Record<string, unknown>;
|
|
318
|
+
const event = body.public;
|
|
319
|
+
if (event === null || typeof event !== "object" || Array.isArray(event)) return null;
|
|
320
|
+
if ((event as Record<string, unknown>).type !== "order.observed") return null;
|
|
321
|
+
return event as Record<string, unknown>;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type CommerceSweepResult =
|
|
325
|
+
| { ok: true; delivered: number; retried: number; dead: number }
|
|
326
|
+
| { ok: false; reason: "scheduler" };
|
|
327
|
+
|
|
328
|
+
export type CommerceDeliver = (event: Record<string, unknown>) => Promise<void>;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Deliver due commerce outbox rows. A scheduler other than the declared alarm
|
|
332
|
+
* leaves every row pending.
|
|
333
|
+
*/
|
|
334
|
+
export async function sweepDurableCommerce(input: {
|
|
335
|
+
db: Queryable;
|
|
336
|
+
scheduler: string;
|
|
337
|
+
now: string;
|
|
338
|
+
deliver?: CommerceDeliver;
|
|
339
|
+
}): Promise<CommerceSweepResult> {
|
|
340
|
+
if (input.scheduler !== COMMERCE_SCHEDULER) return { ok: false, reason: "scheduler" };
|
|
341
|
+
const port = new ScrappyCommerceOutbox(input.db);
|
|
342
|
+
const due = await port.due(input.now);
|
|
343
|
+
let delivered = 0;
|
|
344
|
+
let retried = 0;
|
|
345
|
+
let dead = 0;
|
|
346
|
+
const tenants: { tenantId: string; stage: string }[] = [];
|
|
347
|
+
const seenTenant = (tenantId: string, stage: string) => {
|
|
348
|
+
if (!tenants.some((item) => item.tenantId === tenantId && item.stage === stage)) {
|
|
349
|
+
tenants.push({ tenantId, stage });
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
for (const row of due) {
|
|
353
|
+
seenTenant(row.tenant_id, row.stage);
|
|
354
|
+
const outcome = await settleCommerceRow(port, row, input.now, input.deliver);
|
|
355
|
+
if (outcome === "delivered") delivered += 1;
|
|
356
|
+
else if (outcome === "retried") retried += 1;
|
|
357
|
+
else dead += 1;
|
|
358
|
+
}
|
|
359
|
+
for (const tenant of tenants) await port.noteCursor(tenant.tenantId, tenant.stage, input.now);
|
|
360
|
+
return { ok: true, delivered, retried, dead };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function settleCommerceRow(
|
|
364
|
+
port: ScrappyCommerceOutbox,
|
|
365
|
+
row: CommerceOutboxDue,
|
|
366
|
+
now: string,
|
|
367
|
+
deliver: CommerceDeliver | undefined,
|
|
368
|
+
): Promise<"delivered" | "retried" | "dead"> {
|
|
369
|
+
const event = publicCommercePayload(row.payload);
|
|
370
|
+
if (!event) {
|
|
371
|
+
await port.markDead(row.outbox_id);
|
|
372
|
+
return "dead";
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
if (deliver) await deliver(event);
|
|
376
|
+
await port.markDelivered(row.outbox_id);
|
|
377
|
+
return "delivered";
|
|
378
|
+
} catch {
|
|
379
|
+
const next = row.attempt_count + 1;
|
|
380
|
+
if (next >= MAX_DELIVERY_ATTEMPTS) {
|
|
381
|
+
await port.markDead(row.outbox_id, next);
|
|
382
|
+
return "dead";
|
|
383
|
+
}
|
|
384
|
+
const availableAt = new Date(Date.parse(now) + computeBackoffDelaySeconds(next) * 1000).toISOString();
|
|
385
|
+
await port.markRetry(row.outbox_id, next, availableAt);
|
|
386
|
+
return "retried";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -665,3 +665,73 @@ export class WebhookDeliveryStore {
|
|
|
665
665
|
return toDomain(await this.mustFindDeliveryRow(deliveryId));
|
|
666
666
|
}
|
|
667
667
|
}
|
|
668
|
+
|
|
669
|
+
export interface CommerceOutboxDue {
|
|
670
|
+
outbox_id: string;
|
|
671
|
+
tenant_id: string;
|
|
672
|
+
stage: string;
|
|
673
|
+
payload: string;
|
|
674
|
+
attempt_count: number;
|
|
675
|
+
available_at: string;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Due commerce outbox rows. The order ledger owns the insert; this port owns sweep progress. */
|
|
679
|
+
export class ScrappyCommerceOutbox {
|
|
680
|
+
constructor(private readonly db: Queryable) {}
|
|
681
|
+
|
|
682
|
+
async due(now: string): Promise<CommerceOutboxDue[]> {
|
|
683
|
+
const rows = await this.db.all<CommerceOutboxDue>(
|
|
684
|
+
`SELECT outbox_id, tenant_id, stage, payload, attempt_count, available_at
|
|
685
|
+
FROM scrappy_commerce_outbox
|
|
686
|
+
WHERE state = 'pending' AND available_at <= ?
|
|
687
|
+
ORDER BY available_at`,
|
|
688
|
+
now,
|
|
689
|
+
);
|
|
690
|
+
return rows.map((row) => ({ ...row, attempt_count: Number(row.attempt_count) }));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async markDelivered(outboxId: string): Promise<void> {
|
|
694
|
+
await this.db.run(
|
|
695
|
+
`UPDATE scrappy_commerce_outbox SET state = 'delivered' WHERE outbox_id = ? AND state = 'pending'`,
|
|
696
|
+
outboxId,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async markRetry(outboxId: string, attemptCount: number, availableAt: string): Promise<void> {
|
|
701
|
+
await this.db.run(
|
|
702
|
+
`UPDATE scrappy_commerce_outbox
|
|
703
|
+
SET state = 'pending', attempt_count = ?, available_at = ?
|
|
704
|
+
WHERE outbox_id = ? AND state = 'pending'`,
|
|
705
|
+
attemptCount,
|
|
706
|
+
availableAt,
|
|
707
|
+
outboxId,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async markDead(outboxId: string, attemptCount?: number): Promise<void> {
|
|
712
|
+
if (attemptCount === undefined) {
|
|
713
|
+
await this.db.run(
|
|
714
|
+
`UPDATE scrappy_commerce_outbox SET state = 'dead' WHERE outbox_id = ? AND state = 'pending'`,
|
|
715
|
+
outboxId,
|
|
716
|
+
);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
await this.db.run(
|
|
720
|
+
`UPDATE scrappy_commerce_outbox
|
|
721
|
+
SET state = 'dead', attempt_count = ?
|
|
722
|
+
WHERE outbox_id = ? AND state = 'pending'`,
|
|
723
|
+
attemptCount,
|
|
724
|
+
outboxId,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async noteCursor(tenantId: string, stage: string, cursor: string): Promise<void> {
|
|
729
|
+
await this.db.run(
|
|
730
|
+
`INSERT INTO scrappy_sweep_cursor (tenant_id, stage, cursor) VALUES (?,?,?)
|
|
731
|
+
ON CONFLICT(tenant_id, stage) DO UPDATE SET cursor = excluded.cursor`,
|
|
732
|
+
tenantId,
|
|
733
|
+
stage,
|
|
734
|
+
cursor,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
@@ -15,15 +15,16 @@
|
|
|
15
15
|
* design-resolution + entry-wiring context).
|
|
16
16
|
*/
|
|
17
17
|
import { WebhookDeliveryStore } from "./deliveryStore.js";
|
|
18
|
-
import { attemptDelivery } from "./deliveryEngine.js";
|
|
18
|
+
import { attemptDelivery, sweepDurableCommerce, type CommerceDeliver, type CommerceSweepResult } from "./deliveryEngine.js";
|
|
19
19
|
import { InMemoryDispatcher, type AttemptDeliveryFn, type WebhookDispatcher } from "./dispatcher.js";
|
|
20
20
|
import { CloudflareQueueDispatcher, type CloudflareQueueSender } from "./cloudflareQueueDispatcher.js";
|
|
21
21
|
import { AppRegistryService } from "../apps/registryService.js";
|
|
22
22
|
import { D1CredentialStore } from "../apps/credentials.js";
|
|
23
23
|
import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "../apps/gatewayKeys.js";
|
|
24
24
|
import { json } from "../apps/registryAccess.js";
|
|
25
|
-
import { fromD1 } from "../d1/catalog.js";
|
|
25
|
+
import { fromD1, type Queryable } from "../d1/catalog.js";
|
|
26
26
|
import { readD1, readEnv, readQueue } from "../env.js";
|
|
27
|
+
import { COMMERCE_SCHEDULER } from "@tot/public-runtime";
|
|
27
28
|
|
|
28
29
|
/** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
|
|
29
30
|
export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
|
|
@@ -47,3 +48,29 @@ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
|
|
|
47
48
|
if (queueSender) return new CloudflareQueueDispatcher(queueSender);
|
|
48
49
|
return new InMemoryDispatcher(attempt);
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Commerce work is already durable when it is saved. This dispatcher only
|
|
54
|
+
* sweeps that outbox, and only when the declared scheduler is configured.
|
|
55
|
+
* It never falls back to an in-memory queue.
|
|
56
|
+
*/
|
|
57
|
+
export class CommerceAlarmDispatcher {
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly db: Queryable,
|
|
60
|
+
private readonly scheduler: string,
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
async enqueue(): Promise<void> {}
|
|
64
|
+
|
|
65
|
+
sweep(now: string, deliver?: CommerceDeliver): Promise<CommerceSweepResult> {
|
|
66
|
+
return sweepDurableCommerce({ db: this.db, scheduler: this.scheduler, now, deliver });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getReliableDispatcher(): Promise<CommerceAlarmDispatcher | Response> {
|
|
71
|
+
const scheduler = await readEnv("SCRAPPY_COMMERCE_SCHEDULER");
|
|
72
|
+
if (scheduler !== COMMERCE_SCHEDULER) return json({ error: "commerce scheduler not configured" }, 503);
|
|
73
|
+
const d1 = await readD1("STOREFRONT_APPS_DB");
|
|
74
|
+
if (!d1) return json({ error: "commerce scheduler not configured" }, 503);
|
|
75
|
+
return new CommerceAlarmDispatcher(fromD1(d1), scheduler);
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated owner API for whole-order batch claim and batch read.
|
|
3
|
+
*/
|
|
4
|
+
import type { APIContext } from "astro";
|
|
5
|
+
import { requireAdminApiEntry } from "@/lib/auth/adminApiGuard";
|
|
6
|
+
import {
|
|
7
|
+
fulfillmentResultResponse,
|
|
8
|
+
isOpaqueId,
|
|
9
|
+
openFulfillment,
|
|
10
|
+
parseFulfillmentBody,
|
|
11
|
+
readJsonBody,
|
|
12
|
+
} from "@/lib/fulfillment/fulfillmentService";
|
|
13
|
+
|
|
14
|
+
export const prerender = false;
|
|
15
|
+
|
|
16
|
+
function ownerRefusal(status: number, reason: string): Response {
|
|
17
|
+
return new Response(JSON.stringify({ error: reason }), {
|
|
18
|
+
status,
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": "application/json; charset=utf-8",
|
|
21
|
+
"cache-control": "no-store",
|
|
22
|
+
"x-robots-tag": "noindex, nofollow",
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function requireOwner(context: APIContext): Promise<Response | null> {
|
|
28
|
+
const access = await requireAdminApiEntry(context, { requireOwner: true });
|
|
29
|
+
if (access.ok) return null;
|
|
30
|
+
return ownerRefusal(access.status, access.reason);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
34
|
+
const refused = await requireOwner(context);
|
|
35
|
+
if (refused) return refused;
|
|
36
|
+
const opened = await openFulfillment(context);
|
|
37
|
+
if (!opened.ok) return opened.response;
|
|
38
|
+
const batchId = new URL(context.request.url).searchParams.get("batchId") ?? "";
|
|
39
|
+
if (!isOpaqueId(batchId)) return fulfillmentResultResponse({ ok: false, code: "not-found" }, () => ({}));
|
|
40
|
+
return fulfillmentResultResponse(await opened.service.batch(opened.actor, batchId), (batch) => batch);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
44
|
+
const refused = await requireOwner(context);
|
|
45
|
+
if (refused) return refused;
|
|
46
|
+
const raw = await readJsonBody(context.request);
|
|
47
|
+
if (!raw.ok) return fulfillmentResultResponse(raw, () => ({}));
|
|
48
|
+
const parsed = parseFulfillmentBody(raw.value);
|
|
49
|
+
if (!parsed.ok) return fulfillmentResultResponse(parsed, () => ({}));
|
|
50
|
+
if (!parsed.value.orderIds || parsed.value.orderIds.length === 0) {
|
|
51
|
+
return fulfillmentResultResponse({ ok: false, code: "malformed" }, () => ({}));
|
|
52
|
+
}
|
|
53
|
+
const opened = await openFulfillment(context, parsed.value.tenantId ?? null);
|
|
54
|
+
if (!opened.ok) return opened.response;
|
|
55
|
+
return fulfillmentResultResponse(
|
|
56
|
+
await opened.service.claim(opened.actor, parsed.value.orderIds, parsed.value.expectedVersions),
|
|
57
|
+
(batch) => batch,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-only retrieval of a stored ship-to address.
|
|
3
|
+
* The read runs only after the owner gate admits the caller.
|
|
4
|
+
*/
|
|
5
|
+
import type { APIContext } from "astro";
|
|
6
|
+
import { requireAdminApiEntry } from "@/lib/auth/adminApiGuard";
|
|
7
|
+
import {
|
|
8
|
+
fulfillmentResultResponse,
|
|
9
|
+
isOpaqueId,
|
|
10
|
+
openFulfillment,
|
|
11
|
+
} from "@/lib/fulfillment/fulfillmentService";
|
|
12
|
+
|
|
13
|
+
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
16
|
+
const access = await requireAdminApiEntry(context, { requireOwner: true });
|
|
17
|
+
if (!access.ok) {
|
|
18
|
+
return new Response(JSON.stringify({ error: access.reason }), {
|
|
19
|
+
status: access.status,
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json; charset=utf-8",
|
|
22
|
+
"cache-control": "no-store",
|
|
23
|
+
"x-robots-tag": "noindex, nofollow",
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const orderId = context.params.id ?? "";
|
|
28
|
+
if (!isOpaqueId(orderId)) return fulfillmentResultResponse({ ok: false, code: "not-found" }, () => ({}));
|
|
29
|
+
const opened = await openFulfillment(context);
|
|
30
|
+
if (!opened.ok) return opened.response;
|
|
31
|
+
return fulfillmentResultResponse(await opened.service.readAddress(opened.actor, orderId), (address) => address);
|
|
32
|
+
}
|