@openlup/platform 0.22.0 → 0.24.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 +440 -158
- package/README.md +141 -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 +45 -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 +97 -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 +31 -13
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +107 -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 +51 -0
- package/dist/fulfillment/providerDispatchWorker.js +65 -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/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -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 +110 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +245 -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 +277 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +39 -0
- package/dist/invoicing/index.js +37 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -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 +23 -3
- package/release-gates.json +74 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -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 +24 -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 +94 -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 +32 -13
- package/src/fulfillment/omnipack/index.ts +99 -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 +61 -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/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -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 +71 -0
- package/src/invoicing/contracts/host.ts +84 -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 +102 -0
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +39 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -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,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,30 @@ 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 and minute precision accepted by the base parser,
|
|
243
|
+
// without rewriting key bytes.
|
|
244
|
+
if (original.occurredAt !== undefined && original.occurredAt !== null
|
|
245
|
+
&& (typeof original.occurredAt !== "string" || !timestamp(original.occurredAt)
|
|
246
|
+
|| !z.iso.datetime({ offset: true, local: true })
|
|
247
|
+
.or(z.iso.datetime({ offset: true, local: true, precision: -1 }))
|
|
248
|
+
.safeParse(original.occurredAt.replace(/([+-]\d{2})(\d{2})$/u, "$1:$2")).success))
|
|
249
|
+
return { kind: "refused", refusal: refusal("malformed", "normalize"), diagnostics: { invalidTimestamp: true } };
|
|
250
|
+
const rejectedCorrelationRefs = [original.orderId, original.providerOrderId, original.orderNumber, original.fulfilmentNumber]
|
|
251
|
+
.filter(value => value !== undefined && value !== null && (typeof value !== "string" || !correlationReference(value, config.observationCodecs))).length;
|
|
252
|
+
if (rejectedCorrelationRefs)
|
|
253
|
+
return { kind: "refused", refusal: refusal("missing_correlation", "normalize"), diagnostics: { rejectedCorrelationRefs } };
|
|
254
|
+
const evidence = mapOmnipackWebhookPayload(payload, config.notification.expectedEvent, value => correlationReference(value, config.observationCodecs) !== null, {
|
|
255
|
+
service: value => correlationReference(value, config.observationCodecs) !== null,
|
|
256
|
+
url: value => publicTrackingUrl(value, config.approveTrackingUrl, config.observationCodecs?.trackingUrl) !== null,
|
|
257
|
+
});
|
|
202
258
|
const event = eventKind(evidence.event);
|
|
203
259
|
if (!event || event !== config.notification.expectedEvent) return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
204
|
-
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value =>
|
|
260
|
+
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => correlationReference(value, config.observationCodecs)))
|
|
205
261
|
return { kind: "refused", refusal: refusal("missing_correlation", "normalize") };
|
|
206
262
|
return { kind: "verified", observation: observation({ evidence, event, receivedAt: input.receivedAt,
|
|
207
|
-
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
263
|
+
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs }) };
|
|
208
264
|
} catch { return { kind: "refused", refusal: refusal("malformed", "normalize") }; }
|
|
209
265
|
},
|
|
210
266
|
},
|
|
@@ -219,44 +275,37 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
219
275
|
const matches = collection.rows.filter(row => input.mode === "parcel"
|
|
220
276
|
? row.fulfilmentNumber === input.externalRef : row.providerOrderId === input.externalRef);
|
|
221
277
|
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 }) };
|
|
278
|
+
const mapped = reconciliationObservation(matches[0]!, config, input.fulfillmentRef);
|
|
279
|
+
return mapped.kind === "unmapped" ? { kind: "unknown", refusal: refusal("unsupported_event", "readback") } : { ...mapped, externalRef: input.externalRef! };
|
|
233
280
|
} catch { return { kind: "unknown", refusal: refusal("io_failure", "readback") }; }
|
|
234
281
|
},
|
|
235
282
|
},
|
|
283
|
+
reconciliation: createReconciliationPagePort(client, config, () => !capabilities.selected || !capabilities.readiness.ready
|
|
284
|
+
? refusal(capabilities.selected ? "not_ready" : "disabled", "readback") : null),
|
|
236
285
|
stockSync: {
|
|
237
286
|
async getStock() {
|
|
238
287
|
requireSync();
|
|
239
288
|
try {
|
|
240
289
|
const identity = syncIdentity();
|
|
241
|
-
return (await client.getStock()).map(row => {
|
|
290
|
+
return (await client.getStock({ refuseRedirectRetry: true })).map((row, itemIndex) => {
|
|
242
291
|
const skuRef = reference(row.sku);
|
|
243
|
-
if (!skuRef)
|
|
292
|
+
if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
|
|
244
293
|
return { ...identity, skuRef, quantityKind: "snapshot" as const, totalQuantity: row.totalQuantity,
|
|
245
294
|
forSaleQuantity: row.forSaleQuantity, reservedOrUnavailableQuantity: row.reservedOrUnavailableQuantity };
|
|
246
295
|
});
|
|
247
|
-
} catch { throw
|
|
296
|
+
} catch (error) { throw stockFailure(error, "get_stock"); }
|
|
248
297
|
},
|
|
249
298
|
async getStockMovements(input) {
|
|
250
299
|
requireSync();
|
|
251
300
|
try {
|
|
252
301
|
const identity = syncIdentity();
|
|
253
|
-
return (await client.getStockMovements(input)).map(row => {
|
|
302
|
+
return (await client.getStockMovements(input)).map((row, itemIndex) => {
|
|
254
303
|
const skuRef = reference(row.sku);
|
|
255
|
-
if (!skuRef)
|
|
304
|
+
if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
|
|
256
305
|
return { ...identity, skuRef, quantityKind: "movement" as const, quantity: row.quantity, occurredAt: timestamp(row.occurredAt),
|
|
257
306
|
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
307
|
});
|
|
259
|
-
} catch { throw
|
|
308
|
+
} catch (error) { throw stockFailure(error, "get_stock_movements"); }
|
|
260
309
|
},
|
|
261
310
|
},
|
|
262
311
|
productSync: {
|
|
@@ -267,23 +316,24 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
|
|
|
267
316
|
if (!metadata) return null;
|
|
268
317
|
await client.createProduct(metadata, sku);
|
|
269
318
|
return { replayed: false };
|
|
270
|
-
} catch { throw
|
|
319
|
+
} catch (error) { throw syncFailure("createProduct", error); }
|
|
271
320
|
},
|
|
272
321
|
async getProductPacks(sku) {
|
|
273
322
|
requireSync();
|
|
274
|
-
|
|
275
|
-
|
|
323
|
+
requireProductSku(sku);
|
|
324
|
+
try { return (await client.getProduct(sku)).packs.map(pack => ({ ean: pack.ean, quantity: pack.quantity })); }
|
|
325
|
+
catch (error) { throw syncFailure("getProduct", error); }
|
|
276
326
|
},
|
|
277
327
|
async addProductPack(sku, pack) {
|
|
278
328
|
requireSync();
|
|
279
|
-
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw
|
|
329
|
+
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw new OmnipackSyncError("malformed", "sync");
|
|
280
330
|
try { await client.createProductPack(sku, { ean: pack.ean, quantity: pack.quantity }, pack.ean); }
|
|
281
|
-
catch { throw
|
|
331
|
+
catch (error) { throw syncFailure("createProductPack", error); }
|
|
282
332
|
},
|
|
283
333
|
async listStockedSkus() {
|
|
284
334
|
requireSync();
|
|
285
|
-
try { return [...new Set((await client.getStock()).flatMap(row =>
|
|
286
|
-
catch { throw
|
|
335
|
+
try { return [...new Set((await client.getStock()).flatMap(row => row.sku.trim() ? [row.sku] : []))]; }
|
|
336
|
+
catch (error) { throw syncFailure("getStock", error); }
|
|
287
337
|
},
|
|
288
338
|
},
|
|
289
339
|
};
|
|
@@ -310,3 +360,13 @@ function commandDigest(request: FulfillmentDispatchRequest): string {
|
|
|
310
360
|
pickupPointId: request.selection.pickupPoint?.id ?? null },
|
|
311
361
|
});
|
|
312
362
|
}
|
|
363
|
+
|
|
364
|
+
/** Translate only the contributed client's typed error; never copy a message or payload. */
|
|
365
|
+
function stockFailure(error: unknown, operation: "get_stock" | "get_stock_movements") {
|
|
366
|
+
const failure = syncFailure(operation === "get_stock" ? "getStock" : "getStockMovements", error);
|
|
367
|
+
if (!(error instanceof OmnipackProviderError)) return failure;
|
|
368
|
+
const retryable = error.code !== "redirect_refused" && (error.retryable || error.code === "network_error");
|
|
369
|
+
return Object.assign(failure, { retryable, providerError: {
|
|
370
|
+
operation, status: error.status, code: error.code, retryable,
|
|
371
|
+
} });
|
|
372
|
+
}
|
|
@@ -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),
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import { admittedReference, reference } from "../dispatchProjection.js";
|
|
2
|
+
import { projectObservation, publicTrackingUrl } from "../observationProjection.js";
|
|
3
|
+
export { reference } from "../dispatchProjection.js";
|
|
1
4
|
import { createHash } from "node:crypto";
|
|
2
5
|
import { parseNormalizedFulfillmentFact } from "@openlup/core/fulfillment";
|
|
3
|
-
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentRefusal } from "../contracts/index.js";
|
|
6
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentRefusal } from "../contracts/index.js";
|
|
4
7
|
import type { OmnipackWebhookEvidence } from "./mappers.js";
|
|
5
8
|
import type { OmnipackTrackingReferenceEvidence } from "./trackingReferences.js";
|
|
6
|
-
import { EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
9
|
+
import { buildProviderEventId, EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
7
10
|
import { localStatusFor } from "./omnipackStatusVocabulary.js";
|
|
8
11
|
|
|
9
12
|
export const refusal = (reason: FulfillmentRefusal["reason"], phase: FulfillmentRefusal["phase"], retryable = false): FulfillmentRefusal => ({ reason, phase, retryable });
|
|
10
13
|
export const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
-
/**
|
|
12
|
-
export function
|
|
13
|
-
return
|
|
14
|
+
/** Share admission with the durable sink, including explicit deny allowlists. */
|
|
15
|
+
export function correlationReference(value: string | null | undefined, codecs: OmnipackObservationCodecs = {}): string | null {
|
|
16
|
+
return admittedReference(value, codecs.approveReference);
|
|
17
|
+
}
|
|
18
|
+
export interface OmnipackObservationCodecs extends FulfillmentObservationCodecs {
|
|
19
|
+
normalizeCarrier?(input: { carrierRef: string | null; serviceRef: string | null }): string | null;
|
|
14
20
|
}
|
|
15
21
|
export function timestamp(value: string | null): string | null {
|
|
16
22
|
return value && /^\d{4}-\d\d-\d\dT/u.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
|
|
@@ -18,44 +24,56 @@ export function timestamp(value: string | null): string | null {
|
|
|
18
24
|
export function eventKind(value: string): OmnipackWebhookRouteEvent | null {
|
|
19
25
|
return Object.hasOwn(EVENT_TO_STATUS, value) ? value as OmnipackWebhookRouteEvent : null;
|
|
20
26
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
function tracking(refs: OmnipackTrackingReferenceEvidence[], codecs: OmnipackObservationCodecs, approve: (url: string) => boolean) {
|
|
28
|
+
let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
|
|
29
|
+
const trackingRefs: FulfillmentObservation["trackingRefs"][number][] = [];
|
|
30
|
+
for (const item of refs) {
|
|
31
|
+
if (item.service !== null && !correlationReference(item.service, codecs)) rejectedServices++;
|
|
32
|
+
if (item.trackingUrl !== null && !publicTrackingUrl(item.trackingUrl, approve, codecs.trackingUrl)) rejectedTrackingUrls++;
|
|
33
|
+
const trackingNumber = correlationReference(item.trackingNumber, codecs);
|
|
34
|
+
if (!trackingNumber) { rejectedTrackingRefs++; continue; }
|
|
35
|
+
// Service admission precedes any derivation. A refused service cannot leak a prefix.
|
|
36
|
+
const serviceRef = correlationReference(item.service, codecs);
|
|
37
|
+
const carrierRef = item.service !== null && !serviceRef ? null : reference(codecs.normalizeCarrier
|
|
38
|
+
? codecs.normalizeCarrier({ carrierRef: item.carrier, serviceRef })
|
|
39
|
+
: serviceRef?.split("_")[0]?.toLowerCase());
|
|
40
|
+
trackingRefs.push({ trackingNumber, carrierRef, serviceRef: item.service, publicUrl: item.trackingUrl });
|
|
41
|
+
}
|
|
42
|
+
return { trackingRefs, rejectedTrackingRefs, rejectedServices, rejectedTrackingUrls };
|
|
34
43
|
}
|
|
35
44
|
export function observation(input: {
|
|
36
45
|
evidence: OmnipackWebhookEvidence; event: FulfillmentObservation["eventKind"]; status?: CanonicalLocalFulfillmentStatus;
|
|
37
46
|
receivedAt: string; rawDigest: string; evidenceRef: `evidence:${string}`;
|
|
38
47
|
source: "provider_webhook" | "provider_reconciliation"; fulfillmentRef?: string;
|
|
39
48
|
approveTrackingUrl: (url: string) => boolean;
|
|
49
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
40
50
|
}): FulfillmentObservation {
|
|
41
51
|
const evidence = input.evidence;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
52
|
+
const codecs = input.observationCodecs ?? {};
|
|
53
|
+
const externalRef = correlationReference(evidence.providerOrderId, codecs);
|
|
54
|
+
const providerFulfillmentRef = correlationReference(evidence.fulfilmentNumber, codecs);
|
|
55
|
+
const orderNumber = correlationReference(evidence.orderNumber, codecs);
|
|
45
56
|
const fulfillmentRef = reference(input.fulfillmentRef) ?? providerFulfillmentRef ?? externalRef ?? orderNumber;
|
|
46
|
-
if (!fulfillmentRef) throw refusal("missing_correlation", "normalize");
|
|
57
|
+
if (!fulfillmentRef || input.source === "provider_webhook" && [evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => value !== null && !correlationReference(value, codecs))) throw refusal("missing_correlation", "normalize");
|
|
47
58
|
const occurredAt = timestamp(evidence.occurredAt);
|
|
48
59
|
if (evidence.occurredAt && !occurredAt) throw refusal("malformed", "normalize");
|
|
49
|
-
|
|
60
|
+
// Compose the key with the provider's
|
|
61
|
+
// original bytes, including minute precision and offsets, rather than the UTC fact.
|
|
62
|
+
const key = buildProviderEventId(evidence);
|
|
63
|
+
const normalizedTracking = tracking(evidence.trackingReferences, codecs, input.approveTrackingUrl);
|
|
64
|
+
const rejectedTrackingRefs = normalizedTracking.rejectedTrackingRefs + (evidence.rejectedTrackingRefs ?? 0)
|
|
65
|
+
+ (evidence.rejectedServices ?? normalizedTracking.rejectedServices);
|
|
50
66
|
const status = input.status ?? localStatusFor(EVENT_TO_STATUS[input.event as OmnipackWebhookRouteEvent]);
|
|
51
67
|
const fact = parseNormalizedFulfillmentFact({
|
|
52
|
-
schemaVersion: 1, mapperVersion: "1", factId: key, providerKind: "omnipack", fulfillmentReference: fulfillmentRef,
|
|
68
|
+
schemaVersion: 1, mapperVersion: "1", factId: digest(key), providerKind: "omnipack", fulfillmentReference: reference(fulfillmentRef) ?? digest(fulfillmentRef),
|
|
53
69
|
canonicalStatus: status, provenance: { source: input.source, authority: "provider_asserted" },
|
|
54
70
|
occurredAt, receivedAt: input.receivedAt,
|
|
55
71
|
rawEvent: { reference: input.evidenceRef, digestAlgorithm: "sha256", digest: input.rawDigest },
|
|
56
72
|
}, { isCanonicalStatus: (value): value is CanonicalLocalFulfillmentStatus =>
|
|
57
73
|
["provider_received", "picking", "packed", "in_transit", "delivered", "exception", "cancelled"].includes(value) });
|
|
58
|
-
return { fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
59
|
-
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef, orderNumber, providerFulfillmentRef, parcelRef: null },
|
|
60
|
-
trackingRefs:
|
|
74
|
+
return projectObservation({ fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
75
|
+
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef: evidence.providerOrderId, orderNumber: evidence.orderNumber, providerFulfillmentRef: evidence.fulfilmentNumber, parcelRef: null },
|
|
76
|
+
trackingRefs: normalizedTracking.trackingRefs, diagnostics: { admission: {
|
|
77
|
+
rejectedTrackingRefs, rejectedTrackingUrls: evidence.rejectedTrackingUrls ?? normalizedTracking.rejectedTrackingUrls,
|
|
78
|
+
} } }, input.approveTrackingUrl, codecs);
|
|
61
79
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FulfillmentObservationPagePort, FulfillmentReconciliationObservation } from "../contracts/index.js";
|
|
2
|
+
import { operationError } from "../dispatchProjection.js";
|
|
3
|
+
import { projectPollEvidence, projectObservation } from "../observationProjection.js";
|
|
4
|
+
import { normalizeReconciliationTimestamp } from "../providerReconciliationTimestamp.js";
|
|
5
|
+
import type { OmnipackClient } from "./client.js";
|
|
6
|
+
import { OmnipackProviderError } from "./client.js";
|
|
7
|
+
import type { OmnipackFulfilmentEvidence } from "./mappers.js";
|
|
8
|
+
import { digest, observation, reference, refusal, timestamp, type OmnipackObservationCodecs } from "./observation.js";
|
|
9
|
+
import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
|
|
10
|
+
|
|
11
|
+
type Config = {
|
|
12
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
13
|
+
clock(): string; mintEvidenceReference(): `evidence:${string}`; approveTrackingUrl(url: string): boolean;
|
|
14
|
+
};
|
|
15
|
+
export function reconciliationObservation(row: OmnipackFulfilmentEvidence, config: Config, fulfillmentRef?: string): Extract<FulfillmentReconciliationObservation, { kind: "found" | "unmapped" }> {
|
|
16
|
+
const providerStatus = { primary: row.status, subStatus: row.subStatus };
|
|
17
|
+
// Original status and occurrence bytes remain part of identity, even for aliases
|
|
18
|
+
// mapping to the same canonical event. Received time never changes that identity.
|
|
19
|
+
const eventKey = digest(JSON.stringify([row.providerOrderId, row.fulfilmentNumber, row.orderNumber, row.status, row.subStatus, row.updatedAt]));
|
|
20
|
+
const correlation = { orderRef: null, fulfillmentRef: fulfillmentRef ?? null, externalRef: reference(row.providerOrderId),
|
|
21
|
+
orderNumber: reference(row.orderNumber), providerFulfillmentRef: reference(row.fulfilmentNumber), parcelRef: null };
|
|
22
|
+
const normalized = normalizeStatus(row.status);
|
|
23
|
+
if (!normalized) return { kind: "unmapped", externalRef: correlation.externalRef, evidence: projectPollEvidence({ eventKey, correlation: { ...correlation, externalRef: row.providerOrderId, orderNumber: row.orderNumber, providerFulfillmentRef: row.fulfilmentNumber }, providerStatus }) };
|
|
24
|
+
const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
|
|
25
|
+
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];
|
|
26
|
+
const occurredAt = normalizeReconciliationTimestamp(row.updatedAt);
|
|
27
|
+
const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
|
|
28
|
+
fulfilmentNumber: row.fulfilmentNumber, occurredAt, trackingNumbers: row.trackingNumbers,
|
|
29
|
+
trackingReferences: row.trackingReferences, shippingMethods: [] };
|
|
30
|
+
const mapped = observation({ evidence, event, status, fulfillmentRef, receivedAt: config.clock(),
|
|
31
|
+
rawDigest: digest(JSON.stringify({ ...evidence, providerStatus })), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs });
|
|
32
|
+
return { kind: "found", externalRef: row.providerOrderId ?? "", createdAt: timestamp(row.createdAt), updatedAt: timestamp(occurredAt),
|
|
33
|
+
observation: projectObservation({ ...mapped, eventKey, fact: { ...mapped.fact, factId: eventKey }, providerStatus }, config.approveTrackingUrl, config.observationCodecs) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createReconciliationPagePort(client: OmnipackClient, config: Config, ready: () => ReturnType<typeof refusal> | null): FulfillmentObservationPagePort {
|
|
37
|
+
return {
|
|
38
|
+
async readPage(input) {
|
|
39
|
+
const inactive = ready();
|
|
40
|
+
if (inactive) return { kind: "failed", refusal: inactive };
|
|
41
|
+
if (!Number.isSafeInteger(input.page) || input.page < 0 || !Number.isSafeInteger(input.size) || input.size <= 0)
|
|
42
|
+
return { kind: "failed", refusal: refusal("malformed", "readback") };
|
|
43
|
+
try {
|
|
44
|
+
const rows = await client.getFulfilments(input);
|
|
45
|
+
return { kind: "page", rows: rows.map(row => {
|
|
46
|
+
try { return reconciliationObservation(row, config); }
|
|
47
|
+
catch { return { kind: "unknown" as const, refusal: refusal("malformed", "normalize") }; }
|
|
48
|
+
}) };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof OmnipackProviderError && error.status === 400 && error.code === "ERR_PAGE_MUST_BE_LESS_THAN_TOTAL_PAGES") return { kind: "page_exhausted" };
|
|
51
|
+
return { kind: "failed", refusal: refusal("io_failure", "readback", error instanceof OmnipackProviderError && error.retryable),
|
|
52
|
+
error: operationError(error instanceof OmnipackProviderError ? error : { code: "provider_read_failed", status: null, retryable: false, mayHaveSucceeded: false }) };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -21,7 +21,7 @@ export function createPostgresFulfillmentDispatchStore(db: PgExecutor, host: {
|
|
|
21
21
|
readCandidate: ref => host.readCandidate(ref),
|
|
22
22
|
prepare: ref => host.prepare(ref),
|
|
23
23
|
async read(key) {
|
|
24
|
-
const result = await db.query(
|
|
24
|
+
const result = await db.query("SELECT public.fulfillment_dispatch_read(ref) || jsonb_build_object('acceptance',ref.acceptance) AS response FROM public.fulfillment_dispatch_refs ref WHERE request_key=$1", [key]);
|
|
25
25
|
return result.rows[0]?.response as DispatchRecord | undefined ?? null;
|
|
26
26
|
},
|
|
27
27
|
claim: input => nativeCall(db, 'fulfillment_dispatch_claim', [input.requestKey, input.fulfillmentRef, input.orderRef, input.parcelRef, input.mode, JSON.stringify(dispatchEvidence(input.evidence))]),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
|
+
import { safeDiagnosticCode } from "./dispatchProjection.js";
|
|
1
3
|
import { nativeCall } from './postgresDispatchStore.js';
|
|
2
4
|
import { projectObservation } from './observationProjection.js';
|
|
3
5
|
import type { PgExecutor as PgQueryExecutor } from './postgresDispatchStore.js';
|
|
@@ -64,6 +66,8 @@ const RAISE_EXCEPTION_SQL = `
|
|
|
64
66
|
|
|
65
67
|
export interface PostgresFulfillmentShipmentSpineOptions {
|
|
66
68
|
portKey: string;
|
|
69
|
+
approveTrackingUrl?(url: string): boolean;
|
|
70
|
+
observationCodecs?: FulfillmentObservationCodecs;
|
|
67
71
|
evidenceChannel?: "poll" | "push";
|
|
68
72
|
requiredProviderType?: "simulator";
|
|
69
73
|
}
|
|
@@ -115,7 +119,7 @@ export function createPostgresFulfillmentShipmentSpinePort(
|
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
return {
|
|
118
|
-
localPersistence: localPersistence(executor),
|
|
122
|
+
localPersistence: localPersistence(executor, options.approveTrackingUrl, options.observationCodecs),
|
|
119
123
|
createShipment(request) {
|
|
120
124
|
return callCreate(executor, portKey, [
|
|
121
125
|
request.idempotencyKey,
|
|
@@ -296,7 +300,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
/** G17 native local persistence, composed on the same injected executor. */
|
|
299
|
-
function localPersistence(executor: PgQueryExecutor) {
|
|
303
|
+
function localPersistence(executor: PgQueryExecutor, approveTrackingUrl?: (url: string) => boolean, observationCodecs?: FulfillmentObservationCodecs) {
|
|
300
304
|
return {
|
|
301
305
|
async readLocalFulfillmentState(ref: string) {
|
|
302
306
|
const result = await executor.query(`SELECT shipment.status,shipment.handed_over_at,
|
|
@@ -305,15 +309,28 @@ function localPersistence(executor: PgQueryExecutor) {
|
|
|
305
309
|
const row = result.rows[0];
|
|
306
310
|
return { status: typeof row?.status === 'string' ? row.status : null, latestStatusOccurredAt: date(row?.latest_status_occurred_at), handedOverAt: date(row?.handed_over_at) };
|
|
307
311
|
},
|
|
312
|
+
async readLocalFulfillmentStatus(ref: string) {
|
|
313
|
+
const result = await executor.query('SELECT status FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
|
|
314
|
+
return typeof result.rows[0]?.status === 'string' ? result.rows[0].status : null;
|
|
315
|
+
},
|
|
316
|
+
async latestStatusOccurredAt(ref: string) {
|
|
317
|
+
const result = await executor.query("SELECT max((observation#>>'{fact,occurredAt}')::timestamptz) AS latest_status_occurred_at FROM public.fulfillment_observations WHERE shipment_id=$1::uuid AND status_recorded", [ref]);
|
|
318
|
+
return date(result.rows[0]?.latest_status_occurred_at);
|
|
319
|
+
},
|
|
320
|
+
async readHandedOverAt(ref: string) {
|
|
321
|
+
const result = await executor.query('SELECT handed_over_at FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
|
|
322
|
+
return date(result.rows[0]?.handed_over_at);
|
|
323
|
+
},
|
|
308
324
|
async recordObservation(observation: import('./contracts/index.js').FulfillmentObservation) {
|
|
309
|
-
const safe = projectObservation(observation);
|
|
325
|
+
const safe = projectObservation(observation, approveTrackingUrl, observationCodecs);
|
|
310
326
|
return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_observation',[safe.eventKey,safe.correlation.fulfillmentRef,JSON.stringify(safe),safe.firstReceivedAt]);
|
|
311
327
|
},
|
|
312
328
|
async recordStatusEvidence(ref: { fulfillmentRef: string }, observation: import('./contracts/index.js').FulfillmentObservation) {
|
|
313
329
|
return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_status_evidence',[observation.eventKey,ref.fulfillmentRef]);
|
|
314
330
|
},
|
|
315
|
-
async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal }) {
|
|
331
|
+
async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal; supersededCause?: string }) {
|
|
316
332
|
await nativeCall(executor,'fulfillment_mark_observation_processed',[eventKey,JSON.stringify({ status: outcome.status,
|
|
333
|
+
...(outcome.supersededCause ? { supersededCause: safeDiagnosticCode(outcome.supersededCause) ?? 'fulfillment_state_conflict' } : {}),
|
|
317
334
|
...(outcome.refusal ? { refusal: { reason: outcome.refusal.reason,phase: outcome.refusal.phase,retryable: outcome.refusal.retryable } } : {}) })]);
|
|
318
335
|
},
|
|
319
336
|
};
|
|
@@ -17,14 +17,14 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
|
|
|
17
17
|
const row = result.rows[0];
|
|
18
18
|
if (!row) return null;
|
|
19
19
|
const cursor = row.cursor as Partial<SyncCursor>;
|
|
20
|
-
return { status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
|
|
20
|
+
return { ...cursor, status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
|
|
21
21
|
continuation: cursor.continuation ?? { nextRef: null, startLocalDate: null, endLocalDate: null }, coverageGapSkus: cursor.coverageGapSkus ?? [], refusal: cursor.refusal ?? null };
|
|
22
22
|
},
|
|
23
23
|
async recordCursor(v) {
|
|
24
24
|
await db.query(`INSERT INTO public.fulfillment_stock_sync_cursors(source_key,status,last_stock_synced_at,last_movement_occurred_at,cursor,error_code,updated_at)
|
|
25
25
|
VALUES($1,$2,$3,$4,$5::jsonb,$6,now()) ON CONFLICT(source_key) DO UPDATE SET status=EXCLUDED.status,
|
|
26
26
|
last_stock_synced_at=EXCLUDED.last_stock_synced_at,last_movement_occurred_at=EXCLUDED.last_movement_occurred_at,cursor=EXCLUDED.cursor,error_code=EXCLUDED.error_code,updated_at=now()`,
|
|
27
|
-
[source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.refusal?.reason ?? null]);
|
|
27
|
+
[source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ syncRunId: v.syncRunId, counts: v.counts, items: v.items, alerts: v.alerts, staleSync: v.staleSync, error: v.error, continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.error?.reason ?? v.refusal?.reason ?? null]);
|
|
28
28
|
},
|
|
29
29
|
async readSkuInventoryClasses(skus) {
|
|
30
30
|
if (!skus.length) return new Map();
|
|
@@ -52,7 +52,10 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
|
|
|
52
52
|
const movement = { providerRef, syncRunId, collectedAt, sourceRevision, revisionSource, skuRef, quantityKind, quantity, occurredAt, operationType, documentRef, lotRef, expirationDate };
|
|
53
53
|
return nativeCall(db,'fulfillment_record_stock_movement',[source,v.requestKey,fingerprint({ requestKey: v.requestKey, movement }),JSON.stringify(movement)]);
|
|
54
54
|
},
|
|
55
|
-
recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,
|
|
55
|
+
recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,
|
|
56
|
+
// Quantities, severity and run evidence are observations of the same alert identity.
|
|
57
|
+
fingerprint({ requestKey: v.requestKey, skuRef: v.skuRef, thresholdKind: v.thresholdKind }),
|
|
58
|
+
v.skuRef,v.thresholdKind,v.severity,v.providerForSaleQuantity,v.localAvailableQuantity]),
|
|
56
59
|
resolveLowStockEvidence: v => nativeCall(db,'fulfillment_resolve_stock_evidence',[source,v.skuRef,v.activeThresholdKinds,v.syncRunId]),
|
|
57
60
|
};
|
|
58
61
|
}
|