@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
|
@@ -32,10 +32,34 @@
|
|
|
32
32
|
* compute one FOR either. `rawCustomerId` is accepted and simply unused;
|
|
33
33
|
* see `docs/private-apps/order-forward-contract.md` for the full rationale.
|
|
34
34
|
*/
|
|
35
|
-
import { OrdersStore, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
|
|
35
|
+
import { OrdersStore, ScrappyOrderLedger, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
|
|
36
36
|
import { emitWebhookEvent } from "../../webhooks/emit.js";
|
|
37
37
|
import type { WebhookTopic } from "../../webhooks/deliveryStore.js";
|
|
38
38
|
import type { Queryable } from "../../d1/catalog.js";
|
|
39
|
+
import {
|
|
40
|
+
createLocalJWKSet,
|
|
41
|
+
decodeJwt,
|
|
42
|
+
decodeProtectedHeader,
|
|
43
|
+
errors as joseErrors,
|
|
44
|
+
jwtVerify,
|
|
45
|
+
type JSONWebKeySet,
|
|
46
|
+
type JWK,
|
|
47
|
+
type JWTPayload,
|
|
48
|
+
} from "jose";
|
|
49
|
+
import {
|
|
50
|
+
admitCallerToken,
|
|
51
|
+
canonicalJson,
|
|
52
|
+
sha256Hex,
|
|
53
|
+
type CallerTokenRefusal,
|
|
54
|
+
type CommerceStage,
|
|
55
|
+
type FulfillmentState,
|
|
56
|
+
type PaymentState,
|
|
57
|
+
} from "@tot/public-runtime";
|
|
58
|
+
import {
|
|
59
|
+
normalizeCommerceEvent,
|
|
60
|
+
payloadExposesPrivateIdentity,
|
|
61
|
+
type CommerceCommandName,
|
|
62
|
+
} from "./orderMapper.js";
|
|
39
63
|
|
|
40
64
|
/** One reconciled order, as tot-foxycart forwards it. See the contract doc for the wire (JSON) shape. */
|
|
41
65
|
export interface OrderForwardInput {
|
|
@@ -123,3 +147,181 @@ export async function receiveForwardedOrder(db: Queryable, input: OrderForwardIn
|
|
|
123
147
|
|
|
124
148
|
return { order, previousStatus, emitted };
|
|
125
149
|
}
|
|
150
|
+
|
|
151
|
+
export interface CommerceCallerConfig {
|
|
152
|
+
jwks: JSONWebKeySet;
|
|
153
|
+
trustedIssuers: readonly string[];
|
|
154
|
+
expectedAudience: string;
|
|
155
|
+
activeKids: readonly string[];
|
|
156
|
+
now: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type SignedCommerceRefusal = CallerTokenRefusal | "version" | "malformed" | "totals" | "private" | "replay";
|
|
160
|
+
|
|
161
|
+
export type SignedCommerceResult =
|
|
162
|
+
| {
|
|
163
|
+
ok: true;
|
|
164
|
+
duplicate: boolean;
|
|
165
|
+
payment: PaymentState;
|
|
166
|
+
fulfillment: FulfillmentState;
|
|
167
|
+
eventVersion: number;
|
|
168
|
+
command: CommerceCommandName;
|
|
169
|
+
stage: CommerceStage;
|
|
170
|
+
currency: string;
|
|
171
|
+
tenantId: string;
|
|
172
|
+
}
|
|
173
|
+
| { ok: false; status: number; reason: SignedCommerceRefusal | "order commit failed" };
|
|
174
|
+
|
|
175
|
+
function isStage(value: unknown): value is CommerceStage {
|
|
176
|
+
return value === "preview" || value === "publication";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function publicJwk(key: { kid?: string; kty?: string; crv?: string; use?: string; alg?: string; x?: string }): Record<string, string> {
|
|
180
|
+
return {
|
|
181
|
+
kty: key.kty ?? "",
|
|
182
|
+
crv: key.crv ?? "",
|
|
183
|
+
kid: key.kid ?? "",
|
|
184
|
+
...(key.use ? { use: key.use } : {}),
|
|
185
|
+
...(key.alg ? { alg: key.alg } : {}),
|
|
186
|
+
...(key.x ? { x: key.x } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function expiresAtIso(exp: unknown): string {
|
|
191
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return "";
|
|
192
|
+
return new Date(exp * 1000).toISOString();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Verify a caller-owned EdDSA JWT against the trusted public JWKS. No shared secret. */
|
|
196
|
+
export async function verifySignedCommerceToken(
|
|
197
|
+
token: string,
|
|
198
|
+
config: CommerceCallerConfig,
|
|
199
|
+
binding: { tenantId: string; stage: CommerceStage },
|
|
200
|
+
): Promise<{ ok: true; issuer: string; jti: string; digest: string } | { ok: false; reason: CallerTokenRefusal | "version" }> {
|
|
201
|
+
if (token.split(".").length !== 3) {
|
|
202
|
+
const admitted = admitCallerToken({
|
|
203
|
+
token: { scheme: "shared-secret" },
|
|
204
|
+
trustedIssuers: config.trustedIssuers,
|
|
205
|
+
expectedAudience: config.expectedAudience,
|
|
206
|
+
activeKids: config.activeKids,
|
|
207
|
+
now: config.now,
|
|
208
|
+
tenantId: binding.tenantId,
|
|
209
|
+
stage: binding.stage,
|
|
210
|
+
});
|
|
211
|
+
return admitted.ok ? { ok: false, reason: "shared-secret" } : admitted;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let kid = "";
|
|
215
|
+
try {
|
|
216
|
+
const header = decodeProtectedHeader(token);
|
|
217
|
+
kid = typeof header.kid === "string" ? header.kid : "";
|
|
218
|
+
} catch {
|
|
219
|
+
return { ok: false, reason: "signature" };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const match = config.jwks.keys?.find((key) => key.kid === kid);
|
|
223
|
+
let signatureValid = false;
|
|
224
|
+
let payload: JWTPayload | null = null;
|
|
225
|
+
if (match) {
|
|
226
|
+
try {
|
|
227
|
+
const verified = await jwtVerify(token, createLocalJWKSet({ keys: [publicJwk(match) as JWK] }), {
|
|
228
|
+
currentDate: new Date(config.now),
|
|
229
|
+
});
|
|
230
|
+
signatureValid = true;
|
|
231
|
+
payload = verified.payload;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof joseErrors.JWTExpired || error instanceof joseErrors.JWTClaimValidationFailed) {
|
|
234
|
+
signatureValid = true;
|
|
235
|
+
try {
|
|
236
|
+
payload = decodeJwt(token);
|
|
237
|
+
} catch {
|
|
238
|
+
payload = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const claims = payload ?? {};
|
|
245
|
+
const stage = isStage(claims.stage) ? claims.stage : binding.stage;
|
|
246
|
+
const audience = typeof claims.aud === "string" ? claims.aud : "";
|
|
247
|
+
const admitted = admitCallerToken({
|
|
248
|
+
token: {
|
|
249
|
+
scheme: "asymmetric-jwks",
|
|
250
|
+
signatureValid,
|
|
251
|
+
kid,
|
|
252
|
+
issuer: typeof claims.iss === "string" ? claims.iss : "",
|
|
253
|
+
audience,
|
|
254
|
+
expiresAt: expiresAtIso(claims.exp),
|
|
255
|
+
jti: typeof claims.jti === "string" ? claims.jti : "",
|
|
256
|
+
tenantId: typeof claims.tenantId === "string" ? claims.tenantId : "",
|
|
257
|
+
stage: isStage(claims.stage) ? claims.stage : stage,
|
|
258
|
+
},
|
|
259
|
+
trustedIssuers: config.trustedIssuers,
|
|
260
|
+
expectedAudience: config.expectedAudience,
|
|
261
|
+
activeKids: config.activeKids,
|
|
262
|
+
now: config.now,
|
|
263
|
+
tenantId: binding.tenantId,
|
|
264
|
+
stage: binding.stage,
|
|
265
|
+
});
|
|
266
|
+
if (!isStage(claims.stage)) return { ok: false, reason: admitted.ok ? "stage" : admitted.reason === "signature" ? "signature" : "stage" };
|
|
267
|
+
if (!admitted.ok) return admitted;
|
|
268
|
+
if (claims.ver !== 1) return { ok: false, reason: "version" };
|
|
269
|
+
if (typeof claims.digest !== "string" || claims.digest === "") return { ok: false, reason: "signature" };
|
|
270
|
+
return { ok: true, issuer: typeof claims.iss === "string" ? claims.iss : "", jti: typeof claims.jti === "string" ? claims.jti : "", digest: claims.digest };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Signed-native intake. The order row and its outbox row commit together. */
|
|
274
|
+
export async function receiveSignedCommerceOrder(
|
|
275
|
+
db: Queryable,
|
|
276
|
+
body: unknown,
|
|
277
|
+
token: string,
|
|
278
|
+
config: CommerceCallerConfig,
|
|
279
|
+
): Promise<SignedCommerceResult> {
|
|
280
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
281
|
+
return { ok: false, status: 400, reason: "malformed" };
|
|
282
|
+
}
|
|
283
|
+
const record = body as Record<string, unknown>;
|
|
284
|
+
if (typeof record.tenantId !== "string" || record.tenantId.trim() === "") {
|
|
285
|
+
return { ok: false, status: 401, reason: "tenant" };
|
|
286
|
+
}
|
|
287
|
+
if (!isStage(record.stage)) return { ok: false, status: 401, reason: "stage" };
|
|
288
|
+
if (!("event" in record)) return { ok: false, status: 400, reason: "malformed" };
|
|
289
|
+
|
|
290
|
+
const verified = await verifySignedCommerceToken(token, config, {
|
|
291
|
+
tenantId: record.tenantId,
|
|
292
|
+
stage: record.stage,
|
|
293
|
+
});
|
|
294
|
+
if (!verified.ok) return { ok: false, status: 401, reason: verified.reason };
|
|
295
|
+
if (payloadExposesPrivateIdentity(body)) return { ok: false, status: 400, reason: "private" };
|
|
296
|
+
|
|
297
|
+
const envelope = { tenantId: record.tenantId, stage: record.stage, event: record.event };
|
|
298
|
+
const digest = await sha256Hex(canonicalJson(envelope));
|
|
299
|
+
if (digest !== verified.digest) return { ok: false, status: 401, reason: "signature" };
|
|
300
|
+
|
|
301
|
+
const event = normalizeCommerceEvent(record.event);
|
|
302
|
+
if (event === "malformed" || event === "totals") return { ok: false, status: 400, reason: event };
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const committed = await new ScrappyOrderLedger(db).commit({
|
|
306
|
+
tenantId: record.tenantId,
|
|
307
|
+
stage: record.stage,
|
|
308
|
+
event,
|
|
309
|
+
now: config.now,
|
|
310
|
+
replay: { issuer: verified.issuer, jti: verified.jti },
|
|
311
|
+
});
|
|
312
|
+
if (!committed.ok) return { ok: false, status: 401, reason: "replay" };
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
duplicate: committed.duplicate,
|
|
316
|
+
payment: committed.snapshot.payment,
|
|
317
|
+
fulfillment: committed.snapshot.fulfillment,
|
|
318
|
+
eventVersion: committed.snapshot.eventVersion,
|
|
319
|
+
command: event.command,
|
|
320
|
+
stage: record.stage,
|
|
321
|
+
currency: committed.snapshot.currency,
|
|
322
|
+
tenantId: record.tenantId,
|
|
323
|
+
};
|
|
324
|
+
} catch {
|
|
325
|
+
return { ok: false, status: 500, reason: "order commit failed" };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -10,6 +10,24 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { customerHash } from "./customerHash.js";
|
|
12
12
|
import type { FoxyOrder, FoxyOrderLineItem } from "./foxyOrderClient.js";
|
|
13
|
+
import {
|
|
14
|
+
COMPLIANCE_STATES,
|
|
15
|
+
FULFILLMENT_STATES,
|
|
16
|
+
LABEL_STATES,
|
|
17
|
+
PAYMENT_STATES,
|
|
18
|
+
STOCK_STATES,
|
|
19
|
+
decideCommerceCommand,
|
|
20
|
+
publicCommerceEvent,
|
|
21
|
+
totalsMatch,
|
|
22
|
+
type CommerceEvidence,
|
|
23
|
+
type ComplianceState,
|
|
24
|
+
type FulfillmentState,
|
|
25
|
+
type LabelState,
|
|
26
|
+
type MinorTotals,
|
|
27
|
+
type PaymentState,
|
|
28
|
+
type PublicCommerceEvent,
|
|
29
|
+
type StockState,
|
|
30
|
+
} from "@tot/public-runtime";
|
|
13
31
|
|
|
14
32
|
export type ContractOrderStatus = "created" | "fulfilled" | "cancelled";
|
|
15
33
|
|
|
@@ -89,3 +107,306 @@ export async function toContractOrder(order: FoxyOrder, ctx: ToContractOrderCont
|
|
|
89
107
|
createdAt: toIsoDateTime(order.orderedAt),
|
|
90
108
|
};
|
|
91
109
|
}
|
|
110
|
+
|
|
111
|
+
export const COMMERCE_COMMANDS = ["observe", "capture", "refund", "cancel"] as const;
|
|
112
|
+
export type CommerceCommandName = (typeof COMMERCE_COMMANDS)[number];
|
|
113
|
+
|
|
114
|
+
const PRIVATE_COMMERCE_KEYS = new Set([
|
|
115
|
+
"customer",
|
|
116
|
+
"customerEmail",
|
|
117
|
+
"customerName",
|
|
118
|
+
"email",
|
|
119
|
+
"phone",
|
|
120
|
+
"line1",
|
|
121
|
+
"line2",
|
|
122
|
+
"postalCode",
|
|
123
|
+
"shippingAddress",
|
|
124
|
+
"billingAddress",
|
|
125
|
+
"address",
|
|
126
|
+
"firstName",
|
|
127
|
+
"lastName",
|
|
128
|
+
"name",
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const UNKNOWN_EVIDENCE: CommerceEvidence = {
|
|
132
|
+
verification: "UNKNOWN",
|
|
133
|
+
tax: "UNKNOWN",
|
|
134
|
+
risk: "UNKNOWN",
|
|
135
|
+
stock: "UNKNOWN",
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export interface CommerceSourceEvent {
|
|
139
|
+
eventVersion: number;
|
|
140
|
+
command: CommerceCommandName;
|
|
141
|
+
orderId: string;
|
|
142
|
+
sourceEventId: string;
|
|
143
|
+
source: string;
|
|
144
|
+
sourceOrderId: string;
|
|
145
|
+
placedAt: string;
|
|
146
|
+
currency: string;
|
|
147
|
+
payment: PaymentState;
|
|
148
|
+
compliance: ComplianceState;
|
|
149
|
+
stock: StockState;
|
|
150
|
+
fulfillment: FulfillmentState;
|
|
151
|
+
label: LabelState;
|
|
152
|
+
totals: MinorTotals;
|
|
153
|
+
verificationDetail: CommerceEvidence;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface CommerceSnapshot {
|
|
157
|
+
orderId: string;
|
|
158
|
+
source: string;
|
|
159
|
+
sourceOrderId: string;
|
|
160
|
+
placedAt: string;
|
|
161
|
+
currency: string;
|
|
162
|
+
payment: PaymentState;
|
|
163
|
+
compliance: ComplianceState;
|
|
164
|
+
stock: StockState;
|
|
165
|
+
fulfillment: FulfillmentState;
|
|
166
|
+
label: LabelState;
|
|
167
|
+
totals: MinorTotals;
|
|
168
|
+
verificationDetail: CommerceEvidence;
|
|
169
|
+
eventVersion: number;
|
|
170
|
+
amountMinor: number;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface StoredCommercePayload {
|
|
174
|
+
source: CommerceSourceEvent;
|
|
175
|
+
public: PublicCommerceEvent;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function payloadExposesPrivateIdentity(value: unknown): boolean {
|
|
179
|
+
if (value === null || typeof value !== "object") return false;
|
|
180
|
+
if (Array.isArray(value)) return value.some((item) => payloadExposesPrivateIdentity(item));
|
|
181
|
+
return Object.entries(value as Record<string, unknown>).some(
|
|
182
|
+
([key, child]) => PRIVATE_COMMERCE_KEYS.has(key) || payloadExposesPrivateIdentity(child),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
187
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function commandOf(value: Record<string, unknown>): CommerceCommandName | null {
|
|
191
|
+
const command = value.command ?? value.type;
|
|
192
|
+
if (command === "observe" || command === "order.observed") return "observe";
|
|
193
|
+
if (command === "capture" || command === "order.captured") return "capture";
|
|
194
|
+
if (command === "refund" || command === "order.refunded") return "refund";
|
|
195
|
+
if (command === "cancel" || command === "order.cancelled") return "cancel";
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readTotals(value: unknown): MinorTotals | null {
|
|
200
|
+
if (!isRecord(value)) return null;
|
|
201
|
+
const totals = {
|
|
202
|
+
subtotalMinor: value.subtotalMinor,
|
|
203
|
+
shippingMinor: value.shippingMinor,
|
|
204
|
+
taxMinor: value.taxMinor,
|
|
205
|
+
totalMinor: value.totalMinor,
|
|
206
|
+
};
|
|
207
|
+
if (
|
|
208
|
+
typeof totals.subtotalMinor !== "number" ||
|
|
209
|
+
typeof totals.shippingMinor !== "number" ||
|
|
210
|
+
typeof totals.taxMinor !== "number" ||
|
|
211
|
+
typeof totals.totalMinor !== "number"
|
|
212
|
+
) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return totals as MinorTotals;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readEvidence(value: unknown): CommerceEvidence {
|
|
219
|
+
if (!isRecord(value)) return { ...UNKNOWN_EVIDENCE };
|
|
220
|
+
const pick = (key: keyof CommerceEvidence): CommerceEvidence[keyof CommerceEvidence] => {
|
|
221
|
+
const found = value[key];
|
|
222
|
+
if (found === "fresh" || found === "UNKNOWN" || found === "stale" || found === "unreadable" || found === "absent") {
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
return "UNKNOWN";
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
verification: pick("verification"),
|
|
229
|
+
tax: pick("tax"),
|
|
230
|
+
risk: pick("risk"),
|
|
231
|
+
stock: pick("stock"),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Narrow a signed commerce body to the source event, or a refusal reason. */
|
|
236
|
+
export function normalizeCommerceEvent(value: unknown): CommerceSourceEvent | "malformed" | "totals" {
|
|
237
|
+
if (!isRecord(value)) return "malformed";
|
|
238
|
+
const command = commandOf(value);
|
|
239
|
+
const eventVersion = value.eventVersion;
|
|
240
|
+
const orderId = value.orderId;
|
|
241
|
+
const sourceEventId = value.sourceEventId;
|
|
242
|
+
const placedAt = value.placedAt;
|
|
243
|
+
const currency = value.currency;
|
|
244
|
+
if (command === null) return "malformed";
|
|
245
|
+
if (typeof eventVersion !== "number" || !Number.isSafeInteger(eventVersion) || eventVersion < 1) return "malformed";
|
|
246
|
+
if (typeof orderId !== "string" || orderId.trim() === "" || orderId.includes("\n")) return "malformed";
|
|
247
|
+
if (typeof sourceEventId !== "string" || sourceEventId.trim() === "" || sourceEventId.includes("\n")) return "malformed";
|
|
248
|
+
if (typeof placedAt !== "string" || placedAt.trim() === "") return "malformed";
|
|
249
|
+
if (typeof currency !== "string" || currency.trim() === "") return "malformed";
|
|
250
|
+
const totals = readTotals(value.totals);
|
|
251
|
+
if (totals === null) return "malformed";
|
|
252
|
+
if (!totalsMatch(totals)) return "totals";
|
|
253
|
+
const payment = typeof value.payment === "string" ? value.payment : "UNKNOWN";
|
|
254
|
+
const compliance = typeof value.compliance === "string" ? value.compliance : "UNKNOWN";
|
|
255
|
+
const stock = typeof value.stock === "string" ? value.stock : "unallocated";
|
|
256
|
+
const fulfillment = typeof value.fulfillment === "string" ? value.fulfillment : "held";
|
|
257
|
+
const label = typeof value.label === "string" ? value.label : "none";
|
|
258
|
+
if (!(PAYMENT_STATES as readonly string[]).includes(payment)) return "malformed";
|
|
259
|
+
if (!(COMPLIANCE_STATES as readonly string[]).includes(compliance)) return "malformed";
|
|
260
|
+
if (!(STOCK_STATES as readonly string[]).includes(stock)) return "malformed";
|
|
261
|
+
if (!(FULFILLMENT_STATES as readonly string[]).includes(fulfillment)) return "malformed";
|
|
262
|
+
if (!(LABEL_STATES as readonly string[]).includes(label)) return "malformed";
|
|
263
|
+
return {
|
|
264
|
+
eventVersion,
|
|
265
|
+
command,
|
|
266
|
+
orderId,
|
|
267
|
+
sourceEventId,
|
|
268
|
+
source: typeof value.source === "string" && value.source.trim() !== "" ? value.source : "foxycart",
|
|
269
|
+
sourceOrderId: typeof value.sourceOrderId === "string" && value.sourceOrderId.trim() !== "" ? value.sourceOrderId : orderId,
|
|
270
|
+
placedAt,
|
|
271
|
+
currency,
|
|
272
|
+
payment: payment as PaymentState,
|
|
273
|
+
compliance: compliance as ComplianceState,
|
|
274
|
+
stock: stock as StockState,
|
|
275
|
+
fulfillment: fulfillment as FulfillmentState,
|
|
276
|
+
label: label as LabelState,
|
|
277
|
+
totals,
|
|
278
|
+
verificationDetail: readEvidence(value.verificationDetail),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function toPublicCommerceSnapshot(snapshot: CommerceSnapshot): PublicCommerceEvent {
|
|
283
|
+
return publicCommerceEvent({
|
|
284
|
+
eventVersion: snapshot.eventVersion,
|
|
285
|
+
orderId: snapshot.orderId,
|
|
286
|
+
placedAt: snapshot.placedAt,
|
|
287
|
+
currency: snapshot.currency,
|
|
288
|
+
payment: snapshot.payment,
|
|
289
|
+
compliance: snapshot.compliance,
|
|
290
|
+
stock: snapshot.stock,
|
|
291
|
+
fulfillment: snapshot.fulfillment,
|
|
292
|
+
label: snapshot.label,
|
|
293
|
+
totals: snapshot.totals,
|
|
294
|
+
verificationDetail: snapshot.verificationDetail,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function packCommercePayload(source: CommerceSourceEvent, snapshot: CommerceSnapshot): StoredCommercePayload {
|
|
299
|
+
return { source, public: toPublicCommerceSnapshot(snapshot) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Version-sorted fold. Arrival order does not matter. A capture older than a
|
|
304
|
+
* cancel cannot put payment back to captured or clear the cancellation.
|
|
305
|
+
*/
|
|
306
|
+
export function foldCommerceEvents(events: readonly CommerceSourceEvent[]): CommerceSnapshot {
|
|
307
|
+
const unique = new Map<string, CommerceSourceEvent>();
|
|
308
|
+
for (const event of events) unique.set(`${event.source}\n${event.sourceEventId}`, event);
|
|
309
|
+
const sorted = [...unique.values()].sort((left, right) => {
|
|
310
|
+
if (left.eventVersion !== right.eventVersion) return left.eventVersion - right.eventVersion;
|
|
311
|
+
const rank = { observe: 0, capture: 1, refund: 2, cancel: 3 };
|
|
312
|
+
if (rank[left.command] !== rank[right.command]) return rank[left.command] - rank[right.command];
|
|
313
|
+
return left.sourceEventId < right.sourceEventId ? -1 : left.sourceEventId > right.sourceEventId ? 1 : 0;
|
|
314
|
+
});
|
|
315
|
+
const seed = sorted[0];
|
|
316
|
+
if (seed === undefined) throw new Error("commerce fold requires an event");
|
|
317
|
+
|
|
318
|
+
let placedAt = seed.placedAt;
|
|
319
|
+
let currency = seed.currency;
|
|
320
|
+
let payment: PaymentState = "UNKNOWN";
|
|
321
|
+
let compliance: ComplianceState = "UNKNOWN";
|
|
322
|
+
let stock: StockState = "unallocated";
|
|
323
|
+
let fulfillment: FulfillmentState = "held";
|
|
324
|
+
let label: LabelState = "none";
|
|
325
|
+
let totals = seed.totals;
|
|
326
|
+
let verificationDetail: CommerceEvidence = { ...UNKNOWN_EVIDENCE };
|
|
327
|
+
let paymentVersion = 0;
|
|
328
|
+
let fulfillmentVersion = 0;
|
|
329
|
+
let detailVersion = 0;
|
|
330
|
+
|
|
331
|
+
for (const event of sorted) {
|
|
332
|
+
if (event.eventVersion >= detailVersion) {
|
|
333
|
+
placedAt = event.placedAt;
|
|
334
|
+
currency = event.currency;
|
|
335
|
+
compliance = event.compliance;
|
|
336
|
+
label = event.label;
|
|
337
|
+
totals = event.totals;
|
|
338
|
+
verificationDetail = event.verificationDetail;
|
|
339
|
+
stock = event.stock;
|
|
340
|
+
detailVersion = event.eventVersion;
|
|
341
|
+
}
|
|
342
|
+
if (event.command === "observe" && event.eventVersion >= paymentVersion) {
|
|
343
|
+
payment = event.payment;
|
|
344
|
+
paymentVersion = event.eventVersion;
|
|
345
|
+
}
|
|
346
|
+
if (event.command === "observe" && event.eventVersion >= fulfillmentVersion) {
|
|
347
|
+
fulfillment = event.fulfillment;
|
|
348
|
+
fulfillmentVersion = event.eventVersion;
|
|
349
|
+
}
|
|
350
|
+
if (event.command === "capture" && event.eventVersion >= paymentVersion) {
|
|
351
|
+
payment = "captured";
|
|
352
|
+
paymentVersion = event.eventVersion;
|
|
353
|
+
}
|
|
354
|
+
if (event.command === "refund" && event.eventVersion >= paymentVersion) {
|
|
355
|
+
const decision = decideCommerceCommand({
|
|
356
|
+
command: "refund",
|
|
357
|
+
payment,
|
|
358
|
+
stock,
|
|
359
|
+
fulfillment,
|
|
360
|
+
label,
|
|
361
|
+
evidence: verificationDetail,
|
|
362
|
+
providerOutcome: null,
|
|
363
|
+
});
|
|
364
|
+
if (decision.admit) payment = "refunded";
|
|
365
|
+
else if (decision.reason === "reconcile") payment = "refund_unknown";
|
|
366
|
+
paymentVersion = event.eventVersion;
|
|
367
|
+
}
|
|
368
|
+
if (event.command === "cancel" && event.eventVersion >= fulfillmentVersion) {
|
|
369
|
+
const decision = decideCommerceCommand({
|
|
370
|
+
command: "cancel",
|
|
371
|
+
payment,
|
|
372
|
+
stock,
|
|
373
|
+
fulfillment,
|
|
374
|
+
label,
|
|
375
|
+
evidence: verificationDetail,
|
|
376
|
+
providerOutcome: null,
|
|
377
|
+
});
|
|
378
|
+
if (decision.admit) {
|
|
379
|
+
fulfillment = "cancelled";
|
|
380
|
+
fulfillmentVersion = event.eventVersion;
|
|
381
|
+
if (decision.releaseReservation) stock = "released";
|
|
382
|
+
if (
|
|
383
|
+
event.eventVersion >= paymentVersion &&
|
|
384
|
+
payment !== "refunded" &&
|
|
385
|
+
payment !== "refund_unknown" &&
|
|
386
|
+
payment !== "refund_requested"
|
|
387
|
+
) {
|
|
388
|
+
payment = "voided";
|
|
389
|
+
paymentVersion = event.eventVersion;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const eventVersion = sorted.reduce((max, event) => Math.max(max, event.eventVersion), 1);
|
|
396
|
+
return {
|
|
397
|
+
orderId: seed.orderId,
|
|
398
|
+
source: seed.source,
|
|
399
|
+
sourceOrderId: seed.sourceOrderId,
|
|
400
|
+
placedAt,
|
|
401
|
+
currency,
|
|
402
|
+
payment,
|
|
403
|
+
compliance,
|
|
404
|
+
stock,
|
|
405
|
+
fulfillment,
|
|
406
|
+
label,
|
|
407
|
+
totals,
|
|
408
|
+
verificationDetail,
|
|
409
|
+
eventVersion,
|
|
410
|
+
amountMinor: totals.totalMinor,
|
|
411
|
+
};
|
|
412
|
+
}
|