@openlup/platform 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +222 -99
- package/README.md +59 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +95 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +17 -7
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +104 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
- package/dist/fulfillment/providerDispatchWorker.js +47 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +100 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +246 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +267 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/index.d.ts +1 -0
- package/dist/invoicing/index.js +1 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +22 -2
- package/release-gates.json +56 -3
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +20 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +92 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +19 -7
- package/src/fulfillment/omnipack/index.ts +96 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +48 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +60 -0
- package/src/invoicing/contracts/host.ts +85 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +98 -0
- package/src/invoicing/index.ts +1 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- package/src/subscriptions/renewalIdentity.ts +5 -0
|
@@ -1,18 +1,103 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentOperationError } from "./contracts/index.js";
|
|
2
3
|
|
|
4
|
+
/** Machine identities only: arbitrary exception prose, URLs and credentials are omitted. */
|
|
5
|
+
export function safeDiagnosticCode(value: unknown): string | null {
|
|
6
|
+
return reference(value) && typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_]{0,95}$/u.test(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
export function localFailureReason(stage: string, error: unknown, privacy = durableStrings()): string {
|
|
9
|
+
if (!error || typeof error !== "object") return stage;
|
|
10
|
+
const fields = error as { code?: unknown; causeCode?: unknown; causeMessage?: unknown; message?: unknown };
|
|
11
|
+
const diagnostic = (value: unknown) => typeof value === "string" ? privacy.token(value, safeDiagnosticCode) : null;
|
|
12
|
+
const ownCode = diagnostic(fields.code), causeCode = diagnostic(fields.causeCode), causeMessage = diagnostic(fields.causeMessage);
|
|
13
|
+
const messagePattern = /^([A-Za-z][A-Za-z0-9_]*)(?::([A-Z0-9]{5}))?$/u;
|
|
14
|
+
const safeMessage = typeof fields.message === "string" ? privacy.token(fields.message, value => messagePattern.test(value) ? value : null) : null;
|
|
15
|
+
const message = safeMessage?.match(messagePattern);
|
|
16
|
+
const cause = causeMessage ?? (message && (message[2] || /_(?:failed|down|missing|invalid|conflict|not_found)$/u.test(message[1]!)) ? safeDiagnosticCode(message[1]) : null);
|
|
17
|
+
const code = causeCode ?? ownCode ?? message?.[2];
|
|
18
|
+
return [stage, cause, code].filter(Boolean).join(":");
|
|
19
|
+
}
|
|
20
|
+
export function operationError(error: Omit<FulfillmentOperationError, "cause"> & { cause?: unknown }): FulfillmentOperationError {
|
|
21
|
+
const privacy = durableStrings(error.redactedFieldCount);
|
|
22
|
+
const code = privacy.token(error.code, safeDiagnosticCode) ?? "operation_failed";
|
|
23
|
+
const cause = typeof error.cause === "string" ? privacy.token(error.cause,
|
|
24
|
+
value => /^ack(?::[A-Za-z0-9_]{1,96}){0,2}$/u.test(value) ? value.slice(0,180) : null) : null;
|
|
25
|
+
return { code, status: Number.isInteger(error.status) && error.status! >= 100 && error.status! <= 599 ? error.status : null,
|
|
26
|
+
retryable: error.retryable === true, mayHaveSucceeded: error.mayHaveSucceeded === true,
|
|
27
|
+
...(cause ? { cause } : {}), ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}) };
|
|
28
|
+
}
|
|
29
|
+
// The fulfillment opaque-ID rule; never accept free-form metadata as a reference.
|
|
30
|
+
export const reference = (value: unknown): string | null => typeof value === "string"
|
|
31
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) ? value : null;
|
|
32
|
+
/** Opaque by default; an explicit allowlist can admit wider incumbent references. */
|
|
33
|
+
export function admittedReference(value: string | null | undefined, approve?: (value: string) => boolean): string | null {
|
|
34
|
+
if (typeof value !== "string" || /[\p{Cc}\p{Cf}]/u.test(value)) return null;
|
|
35
|
+
const admitted = approve ? approve(value) : reference(value) !== null;
|
|
36
|
+
const text = value.trim();
|
|
37
|
+
return admitted && text && text.length <= 256 && !/[@]|[a-z][a-z0-9+.-]*:\/\//iu.test(text) ? text : null;
|
|
38
|
+
}
|
|
39
|
+
const isoDate = (value: string | null | undefined): string | null => typeof value === "string"
|
|
40
|
+
&& /^\d{4}-\d\d-\d\d$/u.test(value) && Number.isFinite(Date.parse(value))
|
|
41
|
+
&& new Date(value).toISOString().slice(0, 10) === value ? value : null;
|
|
42
|
+
/** One counted sanitizer for durable projections; codecs are explicit host policy. */
|
|
43
|
+
export function durableStrings(priorCount = 0) {
|
|
44
|
+
let count = Number.isSafeInteger(priorCount) && priorCount > 0 ? priorCount : 0;
|
|
45
|
+
return {
|
|
46
|
+
token(value: string | null | undefined, codec: (value: string) => string | null = reference) {
|
|
47
|
+
if (value == null) return null;
|
|
48
|
+
const safe = codec(value);
|
|
49
|
+
if (safe === null) count += 1;
|
|
50
|
+
return safe;
|
|
51
|
+
},
|
|
52
|
+
get redactedFieldCount() { return count; },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
3
55
|
/** Copy only the durable allowlist, including when an adapter object has extra runtime properties. */
|
|
4
56
|
export function dispatchEvidence(evidence: DispatchEvidence): DispatchEvidence {
|
|
57
|
+
const privacy = durableStrings(evidence.redactedFieldCount ?? evidence.transcript?.redactedFieldCount);
|
|
58
|
+
const sanitize = privacy.token;
|
|
59
|
+
const commandFingerprint = evidence.commandFingerprint;
|
|
60
|
+
const source = evidence.contactRevision.source;
|
|
61
|
+
const providerRef = sanitize(evidence.providerRef) ?? "redacted", carrierRef = sanitize(evidence.carrierRef), serviceRef = sanitize(evidence.serviceRef);
|
|
62
|
+
const mode = evidence.mapping ? (sanitize(evidence.mapping.mode) ?? "shadow") as NonNullable<DispatchEvidence["mapping"]>["mode"] : null;
|
|
63
|
+
const transcript = evidence.transcript ? {
|
|
64
|
+
itemCount: evidence.transcript.items.length,
|
|
65
|
+
items: evidence.transcript.items.map(item => ({ skuRef: sanitize(item.skuRef) ?? "redacted", quantity: Number.isSafeInteger(item.quantity) && item.quantity > 0 ? item.quantity : 0,
|
|
66
|
+
lotRef: sanitize(item.lotRef), expirationDate: sanitize(item.expirationDate, isoDate) })),
|
|
67
|
+
deliveryKind: evidence.transcript.deliveryKind === "parcel-locker" ? "parcel-locker" as const : "courier" as const,
|
|
68
|
+
pickupPointRef: sanitize(evidence.transcript.pickupPointRef),
|
|
69
|
+
...(privacy.redactedFieldCount > 0 ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
|
|
70
|
+
} : null;
|
|
5
71
|
return {
|
|
6
|
-
commandFingerprint
|
|
7
|
-
contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source
|
|
8
|
-
providerRef
|
|
72
|
+
commandFingerprint,
|
|
73
|
+
contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source, revision: evidence.contactRevision.revision },
|
|
74
|
+
providerRef, carrierRef, serviceRef,
|
|
75
|
+
...(transcript ? { transcript } : privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
|
|
76
|
+
...(evidence.mapping ? { mapping: { source: "dispatch_worker" as const, mode: mode!, shadowMode: evidence.mapping.mode === "shadow" } } : {}),
|
|
9
77
|
};
|
|
10
78
|
}
|
|
79
|
+
/** Shadow has a local command identity; it never invokes a provider or resolves credentials. */
|
|
80
|
+
export function shadowDispatchEvidence(request: FulfillmentDispatchRequest): DispatchEvidence {
|
|
81
|
+
if (!request.selection.providerRef || !request.lines.length || request.lines.some(line => !Number.isSafeInteger(line.quantity) || line.quantity <= 0)) throw new Error("dispatch_shadow_command_invalid");
|
|
82
|
+
return dispatchEvidence({ commandFingerprint: createHash("sha256").update(JSON.stringify(request)).digest("hex"),
|
|
83
|
+
contactRevision: request.contactRevision, providerRef: request.selection.providerRef,
|
|
84
|
+
carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
|
|
85
|
+
transcript: { itemCount: request.lines.length, items: request.lines.map(line => ({ skuRef: line.skuRef, quantity: line.quantity,
|
|
86
|
+
lotRef: line.lotRef ?? null, expirationDate: line.expirationDate ?? null })),
|
|
87
|
+
deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
|
|
88
|
+
}
|
|
11
89
|
export function dispatchResult(result: FulfillmentDispatchResult): FulfillmentDispatchResult {
|
|
90
|
+
const privacy = durableStrings(result.redactedFieldCount);
|
|
91
|
+
const externalRef = result.kind === "accepted" ? privacy.token(result.externalRef) ?? "redacted" : null;
|
|
92
|
+
const refusal = result.kind !== "accepted" ? { reason: (privacy.token(result.refusal.reason) ?? "malformed") as typeof result.refusal.reason,
|
|
93
|
+
phase: (privacy.token(result.refusal.phase) ?? "normalize") as typeof result.refusal.phase, retryable: result.refusal.retryable } : null;
|
|
94
|
+
const redactions = privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
|
|
95
|
+
const error = result.error ? { error: operationError({ ...result.error, mayHaveSucceeded: result.kind !== "not_dispatched" }) } : {};
|
|
12
96
|
switch (result.kind) {
|
|
13
|
-
case "accepted": return { kind: "accepted", externalRef:
|
|
14
|
-
|
|
15
|
-
case "
|
|
97
|
+
case "accepted": return { kind: "accepted", externalRef: externalRef!, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, ...redactions, evidence: dispatchEvidence(result.evidence), ...error,
|
|
98
|
+
...(result.recovery ? { recovery: { source: "persisted_acceptance", proof: "persisted_external_ref", noProviderPost: true } } : {}) };
|
|
99
|
+
case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal!, ...redactions, ...error };
|
|
100
|
+
case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: refusal!, ...redactions, ...error };
|
|
16
101
|
}
|
|
17
102
|
}
|
|
18
103
|
export function acceptedProviderProof(record: DispatchRecord | null): record is DispatchRecord & { externalRef: string } {
|
package/src/fulfillment/index.ts
CHANGED
|
@@ -5,9 +5,10 @@ export { createOutboxOrderPaidFulfillmentHandler } from "./paidHandoff.js";
|
|
|
5
5
|
export { collectPaidFulfillmentRecoveryCandidates } from "./paidFulfillmentRecovery.js";
|
|
6
6
|
export { runFulfillmentDispatchWorker } from "./providerDispatchWorker.js";
|
|
7
7
|
export { createFulfillmentCommandLedger } from "./providerCommandLedger.js";
|
|
8
|
-
export {
|
|
8
|
+
export type { FulfillmentIngressCounts, FulfillmentIngressResult } from "./verifiedIngress.js";
|
|
9
|
+
export { verifyAndNormalizeFulfillmentObservation, fulfillmentIngressHttpResult } from "./verifiedIngress.js";
|
|
9
10
|
export { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
|
|
10
|
-
export { reconcileFulfillmentObservations } from "./providerReconciliationWorker.js";
|
|
11
|
+
export { reconcileFulfillmentObservations, runFulfillmentReconciliationPage } from "./providerReconciliationWorker.js";
|
|
11
12
|
export { runFulfillmentStockSyncWorker } from "./providerStockSyncWorker.js";
|
|
12
13
|
export { runFulfillmentProductSyncWorker } from "./providerProductSyncWorker.js";
|
|
13
14
|
export { createFulfillmentLowStockEvidenceHandler } from "./lowStockEvidenceHandler.js";
|
|
@@ -5,4 +5,5 @@ export const FULFILLMENT_MIGRATIONS = [
|
|
|
5
5
|
{ position: 51, catalogPath: "db/platform/migrations/20260819150000_fulfillment_shipment_sequence.sql", sha256: "906706cf66a6a555aa1219b12eb674d937eaa099abde6da18766310af43aaa9f", url: new URL("../../sql/20260819150000_fulfillment_shipment_sequence.sql", import.meta.url) },
|
|
6
6
|
{ position: 73, catalogPath: "db/platform/migrations/20261022080000_fulfillment_command_ledger.sql", sha256: "c6806906cdb0b6931b29f7a5c7c800016e98d04e6ea956f3c1e219a6cdb7e9d1", url: new URL("../../sql/20261022080000_fulfillment_command_ledger.sql", import.meta.url) },
|
|
7
7
|
{ position: 74, catalogPath: "db/platform/migrations/20261022090000_fulfillment_delivery_alignment.sql", sha256: "d9966ae79fe77a0feae8317dc5b46ef2d8196dd0790b1cd0ee1b06ab58061499", url: new URL("../../sql/20261022090000_fulfillment_delivery_alignment.sql", import.meta.url) },
|
|
8
|
+
{ position: 76, catalogPath: "db/platform/migrations/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", sha256: "6d132dec2820047f4e337bcf752b3d884600ccc12de03887fe6d6aa5575bc1db", url: new URL("../../sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", import.meta.url) },
|
|
8
9
|
] as const;
|
|
@@ -1,32 +1,83 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
3
|
import { parseFulfillmentFact } from "./normalizedFulfillmentFacts.js";
|
|
4
|
+
import { admittedReference, durableStrings, reference } from "./dispatchProjection.js";
|
|
3
5
|
import { FULFILLMENT_STATUS_MAP } from "./statusMap.js";
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
type Privacy = ReturnType<typeof durableStrings>;
|
|
8
|
+
const timestamp = z.iso.datetime({ offset: true });
|
|
9
|
+
export function projectCorrelation(value: FulfillmentObservation["correlation"], privacy = durableStrings(), codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation["correlation"] {
|
|
10
|
+
const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
|
|
11
|
+
return { orderRef: value.orderRef, fulfillmentRef: value.fulfillmentRef, externalRef: token(value.externalRef),
|
|
12
|
+
orderNumber: token(value.orderNumber), providerFulfillmentRef: token(value.providerFulfillmentRef), parcelRef: value.parcelRef };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function projectProviderStatus(value: NonNullable<FulfillmentObservation["providerStatus"]>, privacy = durableStrings()): NonNullable<FulfillmentObservation["providerStatus"]> {
|
|
16
|
+
return { primary: privacy.token(value.primary), subStatus: privacy.token(value.subStatus) };
|
|
17
|
+
}
|
|
18
|
+
export type PollEvidence = Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics">;
|
|
19
|
+
function pollEvidence(value: PollEvidence, privacy: Privacy, codecs: FulfillmentObservationCodecs = {}): PollEvidence {
|
|
20
|
+
// Host identities and keys composed from admitted parts are already authoritative.
|
|
21
|
+
return { eventKey: value.eventKey, correlation: projectCorrelation(value.correlation, privacy, codecs),
|
|
22
|
+
...(value.providerStatus ? { providerStatus: projectProviderStatus(value.providerStatus, privacy) } : {}) };
|
|
23
|
+
}
|
|
24
|
+
/** Unknown-status quarantine/ACK evidence has the same privacy boundary as observations. */
|
|
25
|
+
export function projectPollEvidence(value: PollEvidence): PollEvidence {
|
|
26
|
+
const privacy = durableStrings(value.diagnostics?.redactedFieldCount);
|
|
27
|
+
const safe = pollEvidence(value, privacy);
|
|
28
|
+
return { ...safe, ...(privacy.redactedFieldCount ? { diagnostics: { redactedFieldCount: privacy.redactedFieldCount } } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One URL policy: strict default, optional adopter codec, and approval at every sink. */
|
|
32
|
+
export function publicTrackingUrl(value: string, approve?: (url: string) => boolean, codec?: (url: string) => string | null): string | null {
|
|
33
|
+
try {
|
|
34
|
+
const candidate = codec ? codec(value) : value;
|
|
35
|
+
if (!candidate) return null;
|
|
36
|
+
const url = new URL(candidate);
|
|
37
|
+
return url.protocol === "https:" && !url.username && !url.password && (codec || (!url.search && !url.hash)) && approve?.(url.href) ? url.href : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
export function projectTrackingReference(value: FulfillmentObservation["trackingRefs"][number], privacy = durableStrings(), approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}) {
|
|
41
|
+
const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
|
|
42
|
+
const trackingNumber = token(value.trackingNumber);
|
|
43
|
+
if (trackingNumber === null) return null;
|
|
44
|
+
const serviceRef = token(value.serviceRef);
|
|
45
|
+
// Even an opaque-looking derived carrier is unsafe when its service was refused.
|
|
46
|
+
const carrierRef = privacy.token(value.carrierRef, text => value.serviceRef !== null && serviceRef === null ? null : reference(text));
|
|
47
|
+
return { trackingNumber, carrierRef, serviceRef,
|
|
48
|
+
publicUrl: privacy.token(value.publicUrl, url => publicTrackingUrl(url, approveTrackingUrl, codecs.trackingUrl)) };
|
|
8
49
|
}
|
|
9
50
|
|
|
10
51
|
/** New boundary projection: arbitrary adapter properties are never durable. */
|
|
11
|
-
export function projectObservation(value: FulfillmentObservation): FulfillmentObservation {
|
|
52
|
+
export function projectObservation(value: FulfillmentObservation, approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation {
|
|
53
|
+
const privacy = durableStrings(value.diagnostics?.redactedFieldCount), token = privacy.token;
|
|
12
54
|
const diagnostics: NonNullable<FulfillmentObservation["diagnostics"]> = {};
|
|
55
|
+
if (value.diagnostics?.admission) {
|
|
56
|
+
const count = (value: number) => Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
57
|
+
diagnostics.admission = { rejectedTrackingRefs: count(value.diagnostics.admission.rejectedTrackingRefs), rejectedTrackingUrls: count(value.diagnostics.admission.rejectedTrackingUrls) };
|
|
58
|
+
}
|
|
13
59
|
if (value.diagnostics?.refusal) {
|
|
14
60
|
const { reason, phase, retryable } = value.diagnostics.refusal;
|
|
15
|
-
diagnostics.refusal = { reason, phase, retryable };
|
|
61
|
+
diagnostics.refusal = { reason: token(reason) as typeof reason, phase: token(phase) as typeof phase, retryable };
|
|
16
62
|
}
|
|
17
63
|
if (value.diagnostics?.stockContention) diagnostics.stockContention = { sqlState: "55P03", phase: "provider_stock_consumed", retryable: true };
|
|
18
|
-
if (value.diagnostics?.annotation) diagnostics.annotation = value.diagnostics.annotation;
|
|
64
|
+
if (value.diagnostics?.annotation) diagnostics.annotation = token(value.diagnostics.annotation) as typeof value.diagnostics.annotation;
|
|
19
65
|
if (value.diagnostics?.recoveredPickedReceipt) {
|
|
20
66
|
const { receiptRef, closed } = value.diagnostics.recoveredPickedReceipt;
|
|
21
67
|
diagnostics.recoveredPickedReceipt = { receiptRef, closed };
|
|
22
68
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
const fact = parseFulfillmentFact(value.fact);
|
|
70
|
+
const safe = {
|
|
71
|
+
...pollEvidence(value, privacy, codecs),
|
|
72
|
+
fact: { ...fact, mapperVersion: token(fact.mapperVersion) ?? "redacted" }, eventKind: (token(value.eventKind) ?? "redacted") as FulfillmentObservation["eventKind"],
|
|
73
|
+
firstReceivedAt: timestamp.parse(value.firstReceivedAt),
|
|
74
|
+
trackingRefs: value.trackingRefs.flatMap(ref => {
|
|
75
|
+
const projected = projectTrackingReference(ref, privacy, approveTrackingUrl, codecs);
|
|
76
|
+
return projected ? [projected] : [];
|
|
77
|
+
}),
|
|
29
78
|
};
|
|
79
|
+
if (privacy.redactedFieldCount) diagnostics.redactedFieldCount = privacy.redactedFieldCount;
|
|
80
|
+
return { ...safe, ...(Object.keys(diagnostics).length ? { diagnostics } : {}) };
|
|
30
81
|
}
|
|
31
82
|
|
|
32
83
|
const HANDOVER_STATUSES: ReadonlySet<string> = new Set(
|
|
@@ -45,7 +45,7 @@ export interface OmnipackClient {
|
|
|
45
45
|
createProduct(payload: Record<string, unknown>, sku: string): Promise<OmnipackProductCreatedEvidence>;
|
|
46
46
|
getProduct(sku: string): Promise<OmnipackProductEvidence>;
|
|
47
47
|
createProductPack(sku: string, payload: Record<string, unknown>, ean: string): Promise<OmnipackProductPackCreatedEvidence>;
|
|
48
|
-
getStock(): Promise<OmnipackStockEvidence[]>;
|
|
48
|
+
getStock(options?: { refuseRedirectRetry: boolean }): Promise<OmnipackStockEvidence[]>;
|
|
49
49
|
getStockMovements(params?: OmnipackQueryParams): Promise<OmnipackStockMovementEvidence[]>;
|
|
50
50
|
getFulfilmentsPage(params?: OmnipackQueryParams): Promise<{ rows: OmnipackFulfilmentEvidence[]; complete: boolean }>;
|
|
51
51
|
getFulfilments(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentEvidence[]>;
|
|
@@ -87,8 +87,8 @@ export function createOmnipackClient(
|
|
|
87
87
|
return mapOmnipackProductPackCreatedResponse(body, sku, ean);
|
|
88
88
|
},
|
|
89
89
|
|
|
90
|
-
async getStock() {
|
|
91
|
-
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
|
|
90
|
+
async getStock(options) {
|
|
91
|
+
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }, options?.refuseRedirectRetry));
|
|
92
92
|
},
|
|
93
93
|
|
|
94
94
|
async getStockMovements(params) {
|
|
@@ -98,6 +98,7 @@ export function createOmnipackClient(
|
|
|
98
98
|
"getStockMovements",
|
|
99
99
|
withQuery("/stock-movements", params),
|
|
100
100
|
{ method: "GET" },
|
|
101
|
+
true,
|
|
101
102
|
));
|
|
102
103
|
},
|
|
103
104
|
|
|
@@ -141,10 +142,14 @@ async function requestJson(
|
|
|
141
142
|
operation: string,
|
|
142
143
|
path: string,
|
|
143
144
|
init: RequestInit,
|
|
145
|
+
refuseRedirectRetry = false,
|
|
144
146
|
): Promise<unknown> {
|
|
145
147
|
const method = (init.method ?? "GET").toUpperCase();
|
|
146
148
|
const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
|
|
147
|
-
|
|
149
|
+
// Dispatch/readback diagnostics must not change product-sync or stock retry policy.
|
|
150
|
+
const diagnoseRedirectFailure = refuseRedirectRetry || operation === "createOrder"
|
|
151
|
+
|| operation === "getFulfilments" || operation === "getFulfilmentRequests";
|
|
152
|
+
let lastError: OmnipackProviderError | null = null;
|
|
148
153
|
|
|
149
154
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
150
155
|
let submitted = false;
|
|
@@ -161,7 +166,7 @@ async function requestJson(
|
|
|
161
166
|
lastError = new OmnipackProviderError({
|
|
162
167
|
operation,
|
|
163
168
|
status: response.status,
|
|
164
|
-
code: readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
169
|
+
code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
165
170
|
retryable,
|
|
166
171
|
mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
|
|
167
172
|
});
|
|
@@ -173,11 +178,11 @@ async function requestJson(
|
|
|
173
178
|
: new OmnipackProviderError({
|
|
174
179
|
operation,
|
|
175
180
|
status: null,
|
|
176
|
-
code: "network_error",
|
|
181
|
+
code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
|
|
177
182
|
retryable,
|
|
178
183
|
mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
|
|
179
184
|
});
|
|
180
|
-
if (!retryable) throw lastError;
|
|
185
|
+
if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused")) throw lastError;
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
@@ -256,3 +261,10 @@ function readProviderErrorCode(body: unknown): string | null {
|
|
|
256
261
|
function isRetryableStatus(status: number): boolean {
|
|
257
262
|
return status === 408 || status === 429 || status >= 500;
|
|
258
263
|
}
|
|
264
|
+
|
|
265
|
+
// Fetch implementations expose redirect refusal as an exception, sometimes through cause.
|
|
266
|
+
// Inspect it only to select a closed diagnostic; no exception text leaves this adapter.
|
|
267
|
+
function isRedirectRefusal(error: unknown): boolean {
|
|
268
|
+
if (!(error instanceof Error)) return false;
|
|
269
|
+
return /redirect/i.test(error.message) || (error.cause instanceof Error && /redirect/i.test(error.cause.message));
|
|
270
|
+
}
|
|
@@ -1,14 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { publicTrackingUrl } from "../observationProjection.js";
|
|
1
3
|
import type { DispatchEvidence, FulfillmentCapabilityDescriptor, FulfillmentDispatchPort, FulfillmentDispatchRequest,
|
|
2
|
-
FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "../contracts/index.js";
|
|
4
|
+
FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentObservationPagePort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider, FulfillmentRefusal } from "../contracts/index.js";
|
|
3
5
|
import { createOmnipackClient, OmnipackProviderError } from "./client.js";
|
|
4
6
|
import { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
5
7
|
import { buildOmnipackDispatchCommand, serializeOmnipackDispatchPayload, stableDispatchHash } from "./omnipackDispatchPayload.js";
|
|
6
8
|
import { mapOmnipackWebhookPayload } from "./mappers.js";
|
|
7
9
|
import { verifyOmnipackWebhookToken, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
+
import { createReconciliationPagePort, reconciliationObservation } from "./reconciliation.js";
|
|
11
|
+
import { dispatchEvidence, operationError } from "../dispatchProjection.js";
|
|
12
|
+
import { correlationReference, digest, eventKind, observation, reference, refusal, timestamp, type OmnipackObservationCodecs } from "./observation.js";
|
|
10
13
|
export { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
11
14
|
|
|
15
|
+
// Internal error projection: retain diagnostics without exposing a provider payload or cause.
|
|
16
|
+
class OmnipackSyncError extends Error implements FulfillmentRefusal {
|
|
17
|
+
readonly phase = "sync";
|
|
18
|
+
readonly retryable: boolean;
|
|
19
|
+
readonly providerFailure: boolean;
|
|
20
|
+
readonly status: number | null;
|
|
21
|
+
readonly code: string | null;
|
|
22
|
+
|
|
23
|
+
constructor(readonly reason: FulfillmentRefusal["reason"], operation: string, error?: unknown) {
|
|
24
|
+
super(reason === "io_failure" ? `OmniPack request failed: ${operation}` : `OmniPack sync refused: ${reason}`);
|
|
25
|
+
this.name = "OmnipackSyncError";
|
|
26
|
+
this.providerFailure = reason === "io_failure";
|
|
27
|
+
this.retryable = error instanceof OmnipackProviderError ? error.retryable : false;
|
|
28
|
+
this.status = error instanceof OmnipackProviderError ? error.status : null;
|
|
29
|
+
this.code = error instanceof OmnipackProviderError ? error.code : null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function syncFailure(operation: string, error: unknown): OmnipackSyncError {
|
|
33
|
+
return error instanceof OmnipackSyncError ? error : new OmnipackSyncError("io_failure", operation, error);
|
|
34
|
+
}
|
|
35
|
+
function requireProductSku(sku: string): void {
|
|
36
|
+
// URL resolution collapses dot segments; encodeURIComponent rejects lone surrogates.
|
|
37
|
+
// Keep these SKUs in the listing so the worker counts a visible per-SKU failure.
|
|
38
|
+
try {
|
|
39
|
+
if (!sku.trim() || sku === "." || sku === "..") throw Error();
|
|
40
|
+
encodeURIComponent(sku);
|
|
41
|
+
} catch { throw new OmnipackSyncError("malformed", "getProduct"); }
|
|
42
|
+
}
|
|
43
|
+
|
|
12
44
|
/** Node-only, explicit construction. Secrets are supplied per call, never retained as values. */
|
|
13
45
|
export interface OmnipackFulfillmentAdapterConfig {
|
|
14
46
|
http: {
|
|
@@ -27,6 +59,7 @@ export interface OmnipackFulfillmentAdapterConfig {
|
|
|
27
59
|
mintEvidenceReference: () => `evidence:${string}`;
|
|
28
60
|
mintSyncRunId: () => string;
|
|
29
61
|
approveTrackingUrl: (url: string) => boolean;
|
|
62
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
30
63
|
/** Writes occur only through deliberate calls; scheduled composition must remain pull-only. */
|
|
31
64
|
product?: { resolvePushMetadata: (sku: string) => Promise<Record<string, unknown> | null> };
|
|
32
65
|
}
|
|
@@ -38,6 +71,7 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
38
71
|
label: FulfillmentLabelPort;
|
|
39
72
|
observation: FulfillmentObservationPort;
|
|
40
73
|
readback: FulfillmentReadbackPort;
|
|
74
|
+
reconciliation: FulfillmentObservationPagePort;
|
|
41
75
|
stockSync: FulfillmentStockSyncProvider;
|
|
42
76
|
productSync: FulfillmentProductSyncProvider;
|
|
43
77
|
} {
|
|
@@ -72,12 +106,12 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
72
106
|
return null;
|
|
73
107
|
};
|
|
74
108
|
const requireSync = () => {
|
|
75
|
-
if (!capabilities.selected || !capabilities.readiness.ready) throw
|
|
109
|
+
if (!capabilities.selected || !capabilities.readiness.ready) throw new OmnipackSyncError(capabilities.selected ? "not_ready" : "disabled", "selection");
|
|
76
110
|
};
|
|
77
111
|
const syncIdentity = () => {
|
|
78
112
|
const syncRunId = reference(config.mintSyncRunId());
|
|
79
113
|
const collectedAt = timestamp(config.clock());
|
|
80
|
-
if (!syncRunId || !collectedAt) throw
|
|
114
|
+
if (!syncRunId || !collectedAt) throw new OmnipackSyncError("malformed", "sync");
|
|
81
115
|
return { providerRef: capabilities.providerRef, syncRunId, collectedAt, sourceRevision: null, revisionSource: "collection" as const };
|
|
82
116
|
};
|
|
83
117
|
// Shared computation: host metadata and serialized bytes live only for this call.
|
|
@@ -101,9 +135,12 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
101
135
|
selectedDelivery: { providerKind: capabilities.providerRef, carrierKind: request.selection.carrierRef,
|
|
102
136
|
serviceCode: request.selection.serviceRef, deliveryKind: request.selection.deliveryKind, pickupPoint: request.selection.pickupPoint } }, lines,
|
|
103
137
|
});
|
|
104
|
-
const evidence: DispatchEvidence = { commandFingerprint: command.requestFingerprint,
|
|
138
|
+
const evidence: DispatchEvidence = dispatchEvidence({ commandFingerprint: command.requestFingerprint,
|
|
105
139
|
contactRevision: { schemaVersion: request.contactRevision.schemaVersion, source: request.contactRevision.source, revision: request.contactRevision.revision },
|
|
106
|
-
providerRef: capabilities.providerRef, carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef
|
|
140
|
+
providerRef: capabilities.providerRef, carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
|
|
141
|
+
transcript: { itemCount: command.providerPayload.items.length,
|
|
142
|
+
items: command.providerPayload.items.map(item => ({ skuRef: item.sku, quantity: item.quantity, lotRef: item.lotNumber ?? null, expirationDate: item.expirationDate ?? null })),
|
|
143
|
+
deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
|
|
107
144
|
return { body: command.body, evidence };
|
|
108
145
|
};
|
|
109
146
|
return {
|
|
@@ -122,7 +159,7 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
122
159
|
let requestCommandDigest: string;
|
|
123
160
|
try {
|
|
124
161
|
// Validate the closed evidence and its request-bound values without consulting preparation state.
|
|
125
|
-
if (Object.keys(evidence).length !==
|
|
162
|
+
if (Object.keys(evidence).length !== 6 || Object.keys(evidence.contactRevision).length !== 3
|
|
126
163
|
|| typeof evidence.commandFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(evidence.commandFingerprint)
|
|
127
164
|
|| !reference(evidence.providerRef)
|
|
128
165
|
|| !reference(evidence.carrierRef) || !reference(evidence.serviceRef)
|
|
@@ -135,6 +172,7 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
135
172
|
|| evidence.providerRef !== request.selection.providerRef
|
|
136
173
|
|| evidence.carrierRef !== request.selection.carrierRef || evidence.serviceRef !== request.selection.serviceRef) throw Error();
|
|
137
174
|
evidenceKey = serializeOmnipackDispatchPayload(evidence).body;
|
|
175
|
+
if (serializeOmnipackDispatchPayload(dispatchEvidence(evidence)).body !== evidenceKey) throw Error();
|
|
138
176
|
requestCommandDigest = commandDigest(request);
|
|
139
177
|
const submitted = submissions.get(evidence.commandFingerprint);
|
|
140
178
|
if (submitted) {
|
|
@@ -151,7 +189,7 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
151
189
|
let command;
|
|
152
190
|
try {
|
|
153
191
|
command = await buildCommand(commandRequest);
|
|
154
|
-
if (command.evidence.commandFingerprint !== fingerprint) throw Error();
|
|
192
|
+
if (command.evidence.commandFingerprint !== fingerprint || serializeOmnipackDispatchPayload(command.evidence).body !== evidenceKey) throw Error();
|
|
155
193
|
} catch {
|
|
156
194
|
// A stale or unresolved command has never reached credentials or HTTP.
|
|
157
195
|
submissions.delete(fingerprint);
|
|
@@ -166,9 +204,10 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
166
204
|
return { kind: "accepted" as const, externalRef, evidence: structuredClone(command.evidence),
|
|
167
205
|
providerAttemptKey: `attempt:${proof}`, labelKey: `label:${proof}` };
|
|
168
206
|
} catch (error) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
207
|
+
const detail = operationError(error instanceof OmnipackProviderError ? error : { code: "provider_dispatch_failed", status: null, retryable: true, mayHaveSucceeded: true });
|
|
208
|
+
return !detail.mayHaveSucceeded
|
|
209
|
+
? { kind: "not_dispatched" as const, effectMayHaveSucceeded: false as const, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail }
|
|
210
|
+
: { kind: "unknown" as const, effectMayHaveSucceeded: true as const, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail };
|
|
172
211
|
} finally {
|
|
173
212
|
command.body = "";
|
|
174
213
|
submissions.get(fingerprint)!.completed = true;
|
|
@@ -198,13 +237,27 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
198
237
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "refused", refusal: refusal("malformed", "normalize") };
|
|
199
238
|
if ("event" in payload && (typeof payload.event !== "string" || !eventKind(payload.event)))
|
|
200
239
|
return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
201
|
-
const
|
|
240
|
+
const original = payload as Record<string, unknown>;
|
|
241
|
+
// Validate calendar fields here only; keep compact offsets and local
|
|
242
|
+
// timestamps accepted by the base parser, without rewriting key bytes.
|
|
243
|
+
if (original.occurredAt !== undefined && original.occurredAt !== null
|
|
244
|
+
&& (typeof original.occurredAt !== "string" || !timestamp(original.occurredAt)
|
|
245
|
+
|| !z.iso.datetime({ offset: true, local: true }).safeParse(original.occurredAt.replace(/([+-]\d{2})(\d{2})$/u, "$1:$2")).success))
|
|
246
|
+
return { kind: "refused", refusal: refusal("malformed", "normalize"), diagnostics: { invalidTimestamp: true } };
|
|
247
|
+
const rejectedCorrelationRefs = [original.orderId, original.providerOrderId, original.orderNumber, original.fulfilmentNumber]
|
|
248
|
+
.filter(value => value !== undefined && value !== null && (typeof value !== "string" || !correlationReference(value, config.observationCodecs))).length;
|
|
249
|
+
if (rejectedCorrelationRefs)
|
|
250
|
+
return { kind: "refused", refusal: refusal("missing_correlation", "normalize"), diagnostics: { rejectedCorrelationRefs } };
|
|
251
|
+
const evidence = mapOmnipackWebhookPayload(payload, config.notification.expectedEvent, value => correlationReference(value, config.observationCodecs) !== null, {
|
|
252
|
+
service: value => correlationReference(value, config.observationCodecs) !== null,
|
|
253
|
+
url: value => publicTrackingUrl(value, config.approveTrackingUrl, config.observationCodecs?.trackingUrl) !== null,
|
|
254
|
+
});
|
|
202
255
|
const event = eventKind(evidence.event);
|
|
203
256
|
if (!event || event !== config.notification.expectedEvent) return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
204
|
-
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value =>
|
|
257
|
+
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => correlationReference(value, config.observationCodecs)))
|
|
205
258
|
return { kind: "refused", refusal: refusal("missing_correlation", "normalize") };
|
|
206
259
|
return { kind: "verified", observation: observation({ evidence, event, receivedAt: input.receivedAt,
|
|
207
|
-
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
260
|
+
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs }) };
|
|
208
261
|
} catch { return { kind: "refused", refusal: refusal("malformed", "normalize") }; }
|
|
209
262
|
},
|
|
210
263
|
},
|
|
@@ -219,44 +272,37 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
219
272
|
const matches = collection.rows.filter(row => input.mode === "parcel"
|
|
220
273
|
? row.fulfilmentNumber === input.externalRef : row.providerOrderId === input.externalRef);
|
|
221
274
|
if (matches.length !== 1) return { kind: "unknown", refusal: refusal("local_missing", "readback") };
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
if (!normalized) return { kind: "unknown", refusal: refusal("unsupported_event", "readback") };
|
|
225
|
-
const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
|
|
226
|
-
const event = ({ provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" } as const)[status];
|
|
227
|
-
const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
|
|
228
|
-
fulfilmentNumber: row.fulfilmentNumber, occurredAt: row.updatedAt, trackingNumbers: row.trackingNumbers,
|
|
229
|
-
trackingReferences: row.trackingReferences, shippingMethods: [] };
|
|
230
|
-
return { kind: "found", externalRef: input.externalRef!, createdAt: timestamp(row.createdAt), updatedAt: timestamp(row.updatedAt),
|
|
231
|
-
observation: observation({ evidence, event, status, fulfillmentRef: input.fulfillmentRef, receivedAt: config.clock(),
|
|
232
|
-
rawDigest: digest(JSON.stringify(evidence)), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
275
|
+
const mapped = reconciliationObservation(matches[0]!, config, input.fulfillmentRef);
|
|
276
|
+
return mapped.kind === "unmapped" ? { kind: "unknown", refusal: refusal("unsupported_event", "readback") } : { ...mapped, externalRef: input.externalRef! };
|
|
233
277
|
} catch { return { kind: "unknown", refusal: refusal("io_failure", "readback") }; }
|
|
234
278
|
},
|
|
235
279
|
},
|
|
280
|
+
reconciliation: createReconciliationPagePort(client, config, () => !capabilities.selected || !capabilities.readiness.ready
|
|
281
|
+
? refusal(capabilities.selected ? "not_ready" : "disabled", "readback") : null),
|
|
236
282
|
stockSync: {
|
|
237
283
|
async getStock() {
|
|
238
284
|
requireSync();
|
|
239
285
|
try {
|
|
240
286
|
const identity = syncIdentity();
|
|
241
|
-
return (await client.getStock()).map(row => {
|
|
287
|
+
return (await client.getStock({ refuseRedirectRetry: true })).map((row, itemIndex) => {
|
|
242
288
|
const skuRef = reference(row.sku);
|
|
243
|
-
if (!skuRef)
|
|
289
|
+
if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
|
|
244
290
|
return { ...identity, skuRef, quantityKind: "snapshot" as const, totalQuantity: row.totalQuantity,
|
|
245
291
|
forSaleQuantity: row.forSaleQuantity, reservedOrUnavailableQuantity: row.reservedOrUnavailableQuantity };
|
|
246
292
|
});
|
|
247
|
-
} catch { throw
|
|
293
|
+
} catch (error) { throw stockFailure(error, "get_stock"); }
|
|
248
294
|
},
|
|
249
295
|
async getStockMovements(input) {
|
|
250
296
|
requireSync();
|
|
251
297
|
try {
|
|
252
298
|
const identity = syncIdentity();
|
|
253
|
-
return (await client.getStockMovements(input)).map(row => {
|
|
299
|
+
return (await client.getStockMovements(input)).map((row, itemIndex) => {
|
|
254
300
|
const skuRef = reference(row.sku);
|
|
255
|
-
if (!skuRef)
|
|
301
|
+
if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
|
|
256
302
|
return { ...identity, skuRef, quantityKind: "movement" as const, quantity: row.quantity, occurredAt: timestamp(row.occurredAt),
|
|
257
303
|
operationType: reference(row.operationType), documentRef: reference(row.warehouseDocumentNumber), lotRef: reference(row.lotNumber), expirationDate: /^\d{4}-\d\d-\d\d$/u.test(row.expirationDate ?? "") ? row.expirationDate : null };
|
|
258
304
|
});
|
|
259
|
-
} catch { throw
|
|
305
|
+
} catch (error) { throw stockFailure(error, "get_stock_movements"); }
|
|
260
306
|
},
|
|
261
307
|
},
|
|
262
308
|
productSync: {
|
|
@@ -267,23 +313,24 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
267
313
|
if (!metadata) return null;
|
|
268
314
|
await client.createProduct(metadata, sku);
|
|
269
315
|
return { replayed: false };
|
|
270
|
-
} catch { throw
|
|
316
|
+
} catch (error) { throw syncFailure("createProduct", error); }
|
|
271
317
|
},
|
|
272
318
|
async getProductPacks(sku) {
|
|
273
319
|
requireSync();
|
|
274
|
-
|
|
275
|
-
|
|
320
|
+
requireProductSku(sku);
|
|
321
|
+
try { return (await client.getProduct(sku)).packs.map(pack => ({ ean: pack.ean, quantity: pack.quantity })); }
|
|
322
|
+
catch (error) { throw syncFailure("getProduct", error); }
|
|
276
323
|
},
|
|
277
324
|
async addProductPack(sku, pack) {
|
|
278
325
|
requireSync();
|
|
279
|
-
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw
|
|
326
|
+
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw new OmnipackSyncError("malformed", "sync");
|
|
280
327
|
try { await client.createProductPack(sku, { ean: pack.ean, quantity: pack.quantity }, pack.ean); }
|
|
281
|
-
catch { throw
|
|
328
|
+
catch (error) { throw syncFailure("createProductPack", error); }
|
|
282
329
|
},
|
|
283
330
|
async listStockedSkus() {
|
|
284
331
|
requireSync();
|
|
285
|
-
try { return [...new Set((await client.getStock()).flatMap(row =>
|
|
286
|
-
catch { throw
|
|
332
|
+
try { return [...new Set((await client.getStock()).flatMap(row => row.sku.trim() ? [row.sku] : []))]; }
|
|
333
|
+
catch (error) { throw syncFailure("getStock", error); }
|
|
287
334
|
},
|
|
288
335
|
},
|
|
289
336
|
};
|
|
@@ -310,3 +357,13 @@ function commandDigest(request: FulfillmentDispatchRequest): string {
|
|
|
310
357
|
pickupPointId: request.selection.pickupPoint?.id ?? null },
|
|
311
358
|
});
|
|
312
359
|
}
|
|
360
|
+
|
|
361
|
+
/** Translate only the contributed client's typed error; never copy a message or payload. */
|
|
362
|
+
function stockFailure(error: unknown, operation: "get_stock" | "get_stock_movements") {
|
|
363
|
+
const failure = syncFailure(operation === "get_stock" ? "getStock" : "getStockMovements", error);
|
|
364
|
+
if (!(error instanceof OmnipackProviderError)) return failure;
|
|
365
|
+
const retryable = error.code !== "redirect_refused" && (error.retryable || error.code === "network_error");
|
|
366
|
+
return Object.assign(failure, { retryable, providerError: {
|
|
367
|
+
operation, status: error.status, code: error.code, retryable,
|
|
368
|
+
} });
|
|
369
|
+
}
|
|
@@ -58,6 +58,9 @@ export interface OmnipackFulfilmentRequestEvidence {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export interface OmnipackWebhookEvidence {
|
|
61
|
+
rejectedTrackingRefs?: number;
|
|
62
|
+
rejectedServices?: number;
|
|
63
|
+
rejectedTrackingUrls?: number;
|
|
61
64
|
provider: "omnipack";
|
|
62
65
|
event: string;
|
|
63
66
|
providerOrderId: string | null;
|
|
@@ -177,13 +180,31 @@ export function mapOmnipackFulfilmentRequestsResponse(body: unknown): OmnipackFu
|
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
|
|
180
|
-
export function mapOmnipackWebhookPayload(body: unknown, fallbackEvent = ""
|
|
183
|
+
export function mapOmnipackWebhookPayload(body: unknown, fallbackEvent = "", admitTrackingNumber?: (value: string) => boolean,
|
|
184
|
+
admitMetadata?: { service(value: string): boolean; url(value: string): boolean }): OmnipackWebhookEvidence {
|
|
181
185
|
const payload = asRecord(body);
|
|
182
186
|
const shipments = readArray(payload.shipments).map(asRecord);
|
|
183
|
-
|
|
187
|
+
let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
|
|
188
|
+
// Count every supplied spelling before extraction, tracking refusal or first-wins deduplication.
|
|
189
|
+
if (admitMetadata) for (const row of shipments) {
|
|
190
|
+
for (const value of [row.shippingMethod, row.service]) {
|
|
191
|
+
if (value != null && (typeof value !== "string" || !admitMetadata.service(value))) rejectedServices++;
|
|
192
|
+
}
|
|
193
|
+
for (const value of [row.trackingUrl, row.trackingURL, row.trackingLink, row.publicTrackingUrl]) {
|
|
194
|
+
if (value != null && (typeof value !== "string" || !admitMetadata.url(value))) rejectedTrackingUrls++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Admit and count original entries before the contributed extractor trims/deduplicates.
|
|
198
|
+
const admitted = admitTrackingNumber ? shipments.filter(row => {
|
|
199
|
+
if (admitTrackingNumber(readString(row.trackingNo) || readString(row.trackingNumber))) return true;
|
|
200
|
+
rejectedTrackingRefs++; return false;
|
|
201
|
+
}) : shipments;
|
|
202
|
+
const trackingReferences = trackingReferencesFrom({ shipments: admitted });
|
|
184
203
|
return {
|
|
185
204
|
provider: "omnipack",
|
|
186
205
|
event: readString(payload.event) || fallbackEvent,
|
|
206
|
+
...(rejectedTrackingRefs ? { rejectedTrackingRefs } : {}),
|
|
207
|
+
...(admitMetadata ? { rejectedServices, rejectedTrackingUrls } : {}),
|
|
187
208
|
providerOrderId: readNullableString(payload.orderId) ?? readNullableString(payload.providerOrderId),
|
|
188
209
|
orderNumber: readNullableString(payload.orderNumber),
|
|
189
210
|
fulfilmentNumber: readNullableString(payload.fulfilmentNumber),
|