@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
|
@@ -24,11 +24,11 @@ export function createOmnipackClient(config, fetchImpl) {
|
|
|
24
24
|
const body = await requestJson(fetchImpl, config, "createProductPack", path, post(payload));
|
|
25
25
|
return mapOmnipackProductPackCreatedResponse(body, sku, ean);
|
|
26
26
|
},
|
|
27
|
-
async getStock() {
|
|
28
|
-
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
|
|
27
|
+
async getStock(options) {
|
|
28
|
+
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }, options?.refuseRedirectRetry));
|
|
29
29
|
},
|
|
30
30
|
async getStockMovements(params) {
|
|
31
|
-
return mapOmnipackStockMovementsResponse(await requestJson(fetchImpl, config, "getStockMovements", withQuery("/stock-movements", params), { method: "GET" }));
|
|
31
|
+
return mapOmnipackStockMovementsResponse(await requestJson(fetchImpl, config, "getStockMovements", withQuery("/stock-movements", params), { method: "GET" }, true));
|
|
32
32
|
},
|
|
33
33
|
async getFulfilmentsPage(params) {
|
|
34
34
|
const body = await requestJson(fetchImpl, config, "getFulfilments", withQuery("/fulfilments", params), { method: "GET" });
|
|
@@ -49,9 +49,12 @@ export function createOmnipackClient(config, fetchImpl) {
|
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
async function requestJson(fetchImpl, config, operation, path, init) {
|
|
52
|
+
async function requestJson(fetchImpl, config, operation, path, init, refuseRedirectRetry = false) {
|
|
53
53
|
const method = (init.method ?? "GET").toUpperCase();
|
|
54
54
|
const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
|
|
55
|
+
// Dispatch/readback diagnostics must not change product-sync or stock retry policy.
|
|
56
|
+
const diagnoseRedirectFailure = refuseRedirectRetry || operation === "createOrder"
|
|
57
|
+
|| operation === "getFulfilments" || operation === "getFulfilmentRequests";
|
|
55
58
|
let lastError = null;
|
|
56
59
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
57
60
|
let submitted = false;
|
|
@@ -69,7 +72,7 @@ async function requestJson(fetchImpl, config, operation, path, init) {
|
|
|
69
72
|
lastError = new OmnipackProviderError({
|
|
70
73
|
operation,
|
|
71
74
|
status: response.status,
|
|
72
|
-
code: readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
75
|
+
code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
73
76
|
retryable,
|
|
74
77
|
mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
|
|
75
78
|
});
|
|
@@ -83,11 +86,11 @@ async function requestJson(fetchImpl, config, operation, path, init) {
|
|
|
83
86
|
: new OmnipackProviderError({
|
|
84
87
|
operation,
|
|
85
88
|
status: null,
|
|
86
|
-
code: "network_error",
|
|
89
|
+
code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
|
|
87
90
|
retryable,
|
|
88
91
|
mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
|
|
89
92
|
});
|
|
90
|
-
if (!retryable)
|
|
93
|
+
if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused"))
|
|
91
94
|
throw lastError;
|
|
92
95
|
}
|
|
93
96
|
}
|
|
@@ -160,3 +163,10 @@ function readProviderErrorCode(body) {
|
|
|
160
163
|
function isRetryableStatus(status) {
|
|
161
164
|
return status === 408 || status === 429 || status >= 500;
|
|
162
165
|
}
|
|
166
|
+
// Fetch implementations expose redirect refusal as an exception, sometimes through cause.
|
|
167
|
+
// Inspect it only to select a closed diagnostic; no exception text leaves this adapter.
|
|
168
|
+
function isRedirectRefusal(error) {
|
|
169
|
+
if (!(error instanceof Error))
|
|
170
|
+
return false;
|
|
171
|
+
return /redirect/i.test(error.message) || (error.cause instanceof Error && /redirect/i.test(error.cause.message));
|
|
172
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { FulfillmentCapabilityDescriptor, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "../contracts/index.js";
|
|
1
|
+
import type { FulfillmentCapabilityDescriptor, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentObservationPagePort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "../contracts/index.js";
|
|
2
2
|
import { type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
3
|
+
import { type OmnipackObservationCodecs } from "./observation.js";
|
|
3
4
|
export { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
4
5
|
/** Node-only, explicit construction. Secrets are supplied per call, never retained as values. */
|
|
5
6
|
export interface OmnipackFulfillmentAdapterConfig {
|
|
@@ -35,6 +36,7 @@ export interface OmnipackFulfillmentAdapterConfig {
|
|
|
35
36
|
mintEvidenceReference: () => `evidence:${string}`;
|
|
36
37
|
mintSyncRunId: () => string;
|
|
37
38
|
approveTrackingUrl: (url: string) => boolean;
|
|
39
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
38
40
|
/** Writes occur only through deliberate calls; scheduled composition must remain pull-only. */
|
|
39
41
|
product?: {
|
|
40
42
|
resolvePushMetadata: (sku: string) => Promise<Record<string, unknown> | null>;
|
|
@@ -47,6 +49,7 @@ export declare function createOmnipackFulfillmentAdapter(config: OmnipackFulfill
|
|
|
47
49
|
label: FulfillmentLabelPort;
|
|
48
50
|
observation: FulfillmentObservationPort;
|
|
49
51
|
readback: FulfillmentReadbackPort;
|
|
52
|
+
reconciliation: FulfillmentObservationPagePort;
|
|
50
53
|
stockSync: FulfillmentStockSyncProvider;
|
|
51
54
|
productSync: FulfillmentProductSyncProvider;
|
|
52
55
|
};
|
|
@@ -1,11 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { publicTrackingUrl } from "../observationProjection.js";
|
|
1
3
|
import { createOmnipackClient, OmnipackProviderError } from "./client.js";
|
|
2
4
|
import { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
3
5
|
import { buildOmnipackDispatchCommand, serializeOmnipackDispatchPayload, stableDispatchHash } from "./omnipackDispatchPayload.js";
|
|
4
6
|
import { mapOmnipackWebhookPayload } from "./mappers.js";
|
|
5
7
|
import { verifyOmnipackWebhookToken } from "./omnipackWebhookPayload.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { createReconciliationPagePort, reconciliationObservation } from "./reconciliation.js";
|
|
9
|
+
import { dispatchEvidence, operationError } from "../dispatchProjection.js";
|
|
10
|
+
import { correlationReference, digest, eventKind, observation, reference, refusal, timestamp } from "./observation.js";
|
|
8
11
|
export { omnipackFulfillmentCapabilities } from "./capabilities.js";
|
|
12
|
+
// Internal error projection: retain diagnostics without exposing a provider payload or cause.
|
|
13
|
+
class OmnipackSyncError extends Error {
|
|
14
|
+
reason;
|
|
15
|
+
phase = "sync";
|
|
16
|
+
retryable;
|
|
17
|
+
providerFailure;
|
|
18
|
+
status;
|
|
19
|
+
code;
|
|
20
|
+
constructor(reason, operation, error) {
|
|
21
|
+
super(reason === "io_failure" ? `OmniPack request failed: ${operation}` : `OmniPack sync refused: ${reason}`);
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.name = "OmnipackSyncError";
|
|
24
|
+
this.providerFailure = reason === "io_failure";
|
|
25
|
+
this.retryable = error instanceof OmnipackProviderError ? error.retryable : false;
|
|
26
|
+
this.status = error instanceof OmnipackProviderError ? error.status : null;
|
|
27
|
+
this.code = error instanceof OmnipackProviderError ? error.code : null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function syncFailure(operation, error) {
|
|
31
|
+
return error instanceof OmnipackSyncError ? error : new OmnipackSyncError("io_failure", operation, error);
|
|
32
|
+
}
|
|
33
|
+
function requireProductSku(sku) {
|
|
34
|
+
// URL resolution collapses dot segments; encodeURIComponent rejects lone surrogates.
|
|
35
|
+
// Keep these SKUs in the listing so the worker counts a visible per-SKU failure.
|
|
36
|
+
try {
|
|
37
|
+
if (!sku.trim() || sku === "." || sku === "..")
|
|
38
|
+
throw Error();
|
|
39
|
+
encodeURIComponent(sku);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new OmnipackSyncError("malformed", "getProduct");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
9
45
|
/** No route, schedule, environment reader or default binding is installed. */
|
|
10
46
|
export function createOmnipackFulfillmentAdapter(config) {
|
|
11
47
|
const maxRetainedSubmissions = config.maxRetainedSubmissions === undefined ? 10_000 : config.maxRetainedSubmissions;
|
|
@@ -48,13 +84,13 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
48
84
|
};
|
|
49
85
|
const requireSync = () => {
|
|
50
86
|
if (!capabilities.selected || !capabilities.readiness.ready)
|
|
51
|
-
throw
|
|
87
|
+
throw new OmnipackSyncError(capabilities.selected ? "not_ready" : "disabled", "selection");
|
|
52
88
|
};
|
|
53
89
|
const syncIdentity = () => {
|
|
54
90
|
const syncRunId = reference(config.mintSyncRunId());
|
|
55
91
|
const collectedAt = timestamp(config.clock());
|
|
56
92
|
if (!syncRunId || !collectedAt)
|
|
57
|
-
throw
|
|
93
|
+
throw new OmnipackSyncError("malformed", "sync");
|
|
58
94
|
return { providerRef: capabilities.providerRef, syncRunId, collectedAt, sourceRevision: null, revisionSource: "collection" };
|
|
59
95
|
};
|
|
60
96
|
// Shared computation: host metadata and serialized bytes live only for this call.
|
|
@@ -81,9 +117,12 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
81
117
|
selectedDelivery: { providerKind: capabilities.providerRef, carrierKind: request.selection.carrierRef,
|
|
82
118
|
serviceCode: request.selection.serviceRef, deliveryKind: request.selection.deliveryKind, pickupPoint: request.selection.pickupPoint } }, lines,
|
|
83
119
|
});
|
|
84
|
-
const evidence = { commandFingerprint: command.requestFingerprint,
|
|
120
|
+
const evidence = dispatchEvidence({ commandFingerprint: command.requestFingerprint,
|
|
85
121
|
contactRevision: { schemaVersion: request.contactRevision.schemaVersion, source: request.contactRevision.source, revision: request.contactRevision.revision },
|
|
86
|
-
providerRef: capabilities.providerRef, carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef
|
|
122
|
+
providerRef: capabilities.providerRef, carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
|
|
123
|
+
transcript: { itemCount: command.providerPayload.items.length,
|
|
124
|
+
items: command.providerPayload.items.map(item => ({ skuRef: item.sku, quantity: item.quantity, lotRef: item.lotNumber ?? null, expirationDate: item.expirationDate ?? null })),
|
|
125
|
+
deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
|
|
87
126
|
return { body: command.body, evidence };
|
|
88
127
|
};
|
|
89
128
|
return {
|
|
@@ -107,7 +146,7 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
107
146
|
let requestCommandDigest;
|
|
108
147
|
try {
|
|
109
148
|
// Validate the closed evidence and its request-bound values without consulting preparation state.
|
|
110
|
-
if (Object.keys(evidence).length !==
|
|
149
|
+
if (Object.keys(evidence).length !== 6 || Object.keys(evidence.contactRevision).length !== 3
|
|
111
150
|
|| typeof evidence.commandFingerprint !== "string" || !/^[a-f0-9]{64}$/u.test(evidence.commandFingerprint)
|
|
112
151
|
|| !reference(evidence.providerRef)
|
|
113
152
|
|| !reference(evidence.carrierRef) || !reference(evidence.serviceRef)
|
|
@@ -121,6 +160,8 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
121
160
|
|| evidence.carrierRef !== request.selection.carrierRef || evidence.serviceRef !== request.selection.serviceRef)
|
|
122
161
|
throw Error();
|
|
123
162
|
evidenceKey = serializeOmnipackDispatchPayload(evidence).body;
|
|
163
|
+
if (serializeOmnipackDispatchPayload(dispatchEvidence(evidence)).body !== evidenceKey)
|
|
164
|
+
throw Error();
|
|
124
165
|
requestCommandDigest = commandDigest(request);
|
|
125
166
|
const submitted = submissions.get(evidence.commandFingerprint);
|
|
126
167
|
if (submitted) {
|
|
@@ -142,7 +183,7 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
142
183
|
let command;
|
|
143
184
|
try {
|
|
144
185
|
command = await buildCommand(commandRequest);
|
|
145
|
-
if (command.evidence.commandFingerprint !== fingerprint)
|
|
186
|
+
if (command.evidence.commandFingerprint !== fingerprint || serializeOmnipackDispatchPayload(command.evidence).body !== evidenceKey)
|
|
146
187
|
throw Error();
|
|
147
188
|
}
|
|
148
189
|
catch {
|
|
@@ -161,9 +202,10 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
161
202
|
providerAttemptKey: `attempt:${proof}`, labelKey: `label:${proof}` };
|
|
162
203
|
}
|
|
163
204
|
catch (error) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
205
|
+
const detail = operationError(error instanceof OmnipackProviderError ? error : { code: "provider_dispatch_failed", status: null, retryable: true, mayHaveSucceeded: true });
|
|
206
|
+
return !detail.mayHaveSucceeded
|
|
207
|
+
? { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail }
|
|
208
|
+
: { kind: "unknown", effectMayHaveSucceeded: true, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail };
|
|
167
209
|
}
|
|
168
210
|
finally {
|
|
169
211
|
command.body = "";
|
|
@@ -198,14 +240,28 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
198
240
|
return { kind: "refused", refusal: refusal("malformed", "normalize") };
|
|
199
241
|
if ("event" in payload && (typeof payload.event !== "string" || !eventKind(payload.event)))
|
|
200
242
|
return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
201
|
-
const
|
|
243
|
+
const original = payload;
|
|
244
|
+
// Validate calendar fields here only; keep compact offsets and local
|
|
245
|
+
// timestamps accepted by the base parser, without rewriting key bytes.
|
|
246
|
+
if (original.occurredAt !== undefined && original.occurredAt !== null
|
|
247
|
+
&& (typeof original.occurredAt !== "string" || !timestamp(original.occurredAt)
|
|
248
|
+
|| !z.iso.datetime({ offset: true, local: true }).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)
|
|
204
260
|
return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
|
|
205
|
-
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value =>
|
|
261
|
+
if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => correlationReference(value, config.observationCodecs)))
|
|
206
262
|
return { kind: "refused", refusal: refusal("missing_correlation", "normalize") };
|
|
207
263
|
return { kind: "verified", observation: observation({ evidence, event, receivedAt: input.receivedAt,
|
|
208
|
-
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
264
|
+
rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs }) };
|
|
209
265
|
}
|
|
210
266
|
catch {
|
|
211
267
|
return { kind: "refused", refusal: refusal("malformed", "normalize") };
|
|
@@ -228,55 +284,47 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
228
284
|
? row.fulfilmentNumber === input.externalRef : row.providerOrderId === input.externalRef);
|
|
229
285
|
if (matches.length !== 1)
|
|
230
286
|
return { kind: "unknown", refusal: refusal("local_missing", "readback") };
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
if (!normalized)
|
|
234
|
-
return { kind: "unknown", refusal: refusal("unsupported_event", "readback") };
|
|
235
|
-
const status = localStatusFor(normalized);
|
|
236
|
-
const event = { provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" }[status];
|
|
237
|
-
const evidence = { provider: "omnipack", event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
|
|
238
|
-
fulfilmentNumber: row.fulfilmentNumber, occurredAt: row.updatedAt, trackingNumbers: row.trackingNumbers,
|
|
239
|
-
trackingReferences: row.trackingReferences, shippingMethods: [] };
|
|
240
|
-
return { kind: "found", externalRef: input.externalRef, createdAt: timestamp(row.createdAt), updatedAt: timestamp(row.updatedAt),
|
|
241
|
-
observation: observation({ evidence, event, status, fulfillmentRef: input.fulfillmentRef, receivedAt: config.clock(),
|
|
242
|
-
rawDigest: digest(JSON.stringify(evidence)), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl }) };
|
|
287
|
+
const mapped = reconciliationObservation(matches[0], config, input.fulfillmentRef);
|
|
288
|
+
return mapped.kind === "unmapped" ? { kind: "unknown", refusal: refusal("unsupported_event", "readback") } : { ...mapped, externalRef: input.externalRef };
|
|
243
289
|
}
|
|
244
290
|
catch {
|
|
245
291
|
return { kind: "unknown", refusal: refusal("io_failure", "readback") };
|
|
246
292
|
}
|
|
247
293
|
},
|
|
248
294
|
},
|
|
295
|
+
reconciliation: createReconciliationPagePort(client, config, () => !capabilities.selected || !capabilities.readiness.ready
|
|
296
|
+
? refusal(capabilities.selected ? "not_ready" : "disabled", "readback") : null),
|
|
249
297
|
stockSync: {
|
|
250
298
|
async getStock() {
|
|
251
299
|
requireSync();
|
|
252
300
|
try {
|
|
253
301
|
const identity = syncIdentity();
|
|
254
|
-
return (await client.getStock()).map(row => {
|
|
302
|
+
return (await client.getStock({ refuseRedirectRetry: true })).map((row, itemIndex) => {
|
|
255
303
|
const skuRef = reference(row.sku);
|
|
256
304
|
if (!skuRef)
|
|
257
|
-
|
|
305
|
+
return { kind: "rejected", itemIndex, reason: "invalid_sku_ref" };
|
|
258
306
|
return { ...identity, skuRef, quantityKind: "snapshot", totalQuantity: row.totalQuantity,
|
|
259
307
|
forSaleQuantity: row.forSaleQuantity, reservedOrUnavailableQuantity: row.reservedOrUnavailableQuantity };
|
|
260
308
|
});
|
|
261
309
|
}
|
|
262
|
-
catch {
|
|
263
|
-
throw
|
|
310
|
+
catch (error) {
|
|
311
|
+
throw stockFailure(error, "get_stock");
|
|
264
312
|
}
|
|
265
313
|
},
|
|
266
314
|
async getStockMovements(input) {
|
|
267
315
|
requireSync();
|
|
268
316
|
try {
|
|
269
317
|
const identity = syncIdentity();
|
|
270
|
-
return (await client.getStockMovements(input)).map(row => {
|
|
318
|
+
return (await client.getStockMovements(input)).map((row, itemIndex) => {
|
|
271
319
|
const skuRef = reference(row.sku);
|
|
272
320
|
if (!skuRef)
|
|
273
|
-
|
|
321
|
+
return { kind: "rejected", itemIndex, reason: "invalid_sku_ref" };
|
|
274
322
|
return { ...identity, skuRef, quantityKind: "movement", quantity: row.quantity, occurredAt: timestamp(row.occurredAt),
|
|
275
323
|
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 };
|
|
276
324
|
});
|
|
277
325
|
}
|
|
278
|
-
catch {
|
|
279
|
-
throw
|
|
326
|
+
catch (error) {
|
|
327
|
+
throw stockFailure(error, "get_stock_movements");
|
|
280
328
|
}
|
|
281
329
|
},
|
|
282
330
|
},
|
|
@@ -290,37 +338,38 @@ export function createOmnipackFulfillmentAdapter(config) {
|
|
|
290
338
|
await client.createProduct(metadata, sku);
|
|
291
339
|
return { replayed: false };
|
|
292
340
|
}
|
|
293
|
-
catch {
|
|
294
|
-
throw
|
|
341
|
+
catch (error) {
|
|
342
|
+
throw syncFailure("createProduct", error);
|
|
295
343
|
}
|
|
296
344
|
},
|
|
297
345
|
async getProductPacks(sku) {
|
|
298
346
|
requireSync();
|
|
347
|
+
requireProductSku(sku);
|
|
299
348
|
try {
|
|
300
|
-
return (await client.getProduct(sku)).packs.
|
|
349
|
+
return (await client.getProduct(sku)).packs.map(pack => ({ ean: pack.ean, quantity: pack.quantity }));
|
|
301
350
|
}
|
|
302
|
-
catch {
|
|
303
|
-
throw
|
|
351
|
+
catch (error) {
|
|
352
|
+
throw syncFailure("getProduct", error);
|
|
304
353
|
}
|
|
305
354
|
},
|
|
306
355
|
async addProductPack(sku, pack) {
|
|
307
356
|
requireSync();
|
|
308
357
|
if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0)
|
|
309
|
-
throw
|
|
358
|
+
throw new OmnipackSyncError("malformed", "sync");
|
|
310
359
|
try {
|
|
311
360
|
await client.createProductPack(sku, { ean: pack.ean, quantity: pack.quantity }, pack.ean);
|
|
312
361
|
}
|
|
313
|
-
catch {
|
|
314
|
-
throw
|
|
362
|
+
catch (error) {
|
|
363
|
+
throw syncFailure("createProductPack", error);
|
|
315
364
|
}
|
|
316
365
|
},
|
|
317
366
|
async listStockedSkus() {
|
|
318
367
|
requireSync();
|
|
319
368
|
try {
|
|
320
|
-
return [...new Set((await client.getStock()).flatMap(row =>
|
|
369
|
+
return [...new Set((await client.getStock()).flatMap(row => row.sku.trim() ? [row.sku] : []))];
|
|
321
370
|
}
|
|
322
|
-
catch {
|
|
323
|
-
throw
|
|
371
|
+
catch (error) {
|
|
372
|
+
throw syncFailure("getStock", error);
|
|
324
373
|
}
|
|
325
374
|
},
|
|
326
375
|
},
|
|
@@ -347,3 +396,13 @@ function commandDigest(request) {
|
|
|
347
396
|
pickupPointId: request.selection.pickupPoint?.id ?? null },
|
|
348
397
|
});
|
|
349
398
|
}
|
|
399
|
+
/** Translate only the contributed client's typed error; never copy a message or payload. */
|
|
400
|
+
function stockFailure(error, operation) {
|
|
401
|
+
const failure = syncFailure(operation === "get_stock" ? "getStock" : "getStockMovements", error);
|
|
402
|
+
if (!(error instanceof OmnipackProviderError))
|
|
403
|
+
return failure;
|
|
404
|
+
const retryable = error.code !== "redirect_refused" && (error.retryable || error.code === "network_error");
|
|
405
|
+
return Object.assign(failure, { retryable, providerError: {
|
|
406
|
+
operation, status: error.status, code: error.code, retryable,
|
|
407
|
+
} });
|
|
408
|
+
}
|
|
@@ -53,6 +53,9 @@ export interface OmnipackFulfilmentRequestEvidence {
|
|
|
53
53
|
state: string | null;
|
|
54
54
|
}
|
|
55
55
|
export interface OmnipackWebhookEvidence {
|
|
56
|
+
rejectedTrackingRefs?: number;
|
|
57
|
+
rejectedServices?: number;
|
|
58
|
+
rejectedTrackingUrls?: number;
|
|
56
59
|
provider: "omnipack";
|
|
57
60
|
event: string;
|
|
58
61
|
providerOrderId: string | null;
|
|
@@ -69,5 +72,8 @@ export declare function mapOmnipackStockResponse(body: unknown): OmnipackStockEv
|
|
|
69
72
|
export declare function mapOmnipackStockMovementsResponse(body: unknown): OmnipackStockMovementEvidence[];
|
|
70
73
|
export declare function mapOmnipackFulfilmentsResponse(body: unknown): OmnipackFulfilmentEvidence[];
|
|
71
74
|
export declare function mapOmnipackFulfilmentRequestsResponse(body: unknown): OmnipackFulfilmentRequestEvidence[];
|
|
72
|
-
export declare function mapOmnipackWebhookPayload(body: unknown, fallbackEvent?: string
|
|
75
|
+
export declare function mapOmnipackWebhookPayload(body: unknown, fallbackEvent?: string, admitTrackingNumber?: (value: string) => boolean, admitMetadata?: {
|
|
76
|
+
service(value: string): boolean;
|
|
77
|
+
url(value: string): boolean;
|
|
78
|
+
}): OmnipackWebhookEvidence;
|
|
73
79
|
export declare function sanitizeOmnipackPayload(input: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -102,13 +102,35 @@ export function mapOmnipackFulfilmentRequestsResponse(body) {
|
|
|
102
102
|
};
|
|
103
103
|
});
|
|
104
104
|
}
|
|
105
|
-
export function mapOmnipackWebhookPayload(body, fallbackEvent = "") {
|
|
105
|
+
export function mapOmnipackWebhookPayload(body, fallbackEvent = "", admitTrackingNumber, admitMetadata) {
|
|
106
106
|
const payload = asRecord(body);
|
|
107
107
|
const shipments = readArray(payload.shipments).map(asRecord);
|
|
108
|
-
|
|
108
|
+
let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
|
|
109
|
+
// Count every supplied spelling before extraction, tracking refusal or first-wins deduplication.
|
|
110
|
+
if (admitMetadata)
|
|
111
|
+
for (const row of shipments) {
|
|
112
|
+
for (const value of [row.shippingMethod, row.service]) {
|
|
113
|
+
if (value != null && (typeof value !== "string" || !admitMetadata.service(value)))
|
|
114
|
+
rejectedServices++;
|
|
115
|
+
}
|
|
116
|
+
for (const value of [row.trackingUrl, row.trackingURL, row.trackingLink, row.publicTrackingUrl]) {
|
|
117
|
+
if (value != null && (typeof value !== "string" || !admitMetadata.url(value)))
|
|
118
|
+
rejectedTrackingUrls++;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Admit and count original entries before the contributed extractor trims/deduplicates.
|
|
122
|
+
const admitted = admitTrackingNumber ? shipments.filter(row => {
|
|
123
|
+
if (admitTrackingNumber(readString(row.trackingNo) || readString(row.trackingNumber)))
|
|
124
|
+
return true;
|
|
125
|
+
rejectedTrackingRefs++;
|
|
126
|
+
return false;
|
|
127
|
+
}) : shipments;
|
|
128
|
+
const trackingReferences = trackingReferencesFrom({ shipments: admitted });
|
|
109
129
|
return {
|
|
110
130
|
provider: "omnipack",
|
|
111
131
|
event: readString(payload.event) || fallbackEvent,
|
|
132
|
+
...(rejectedTrackingRefs ? { rejectedTrackingRefs } : {}),
|
|
133
|
+
...(admitMetadata ? { rejectedServices, rejectedTrackingUrls } : {}),
|
|
112
134
|
providerOrderId: readNullableString(payload.orderId) ?? readNullableString(payload.providerOrderId),
|
|
113
135
|
orderNumber: readNullableString(payload.orderNumber),
|
|
114
136
|
fulfilmentNumber: readNullableString(payload.fulfilmentNumber),
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
export { reference } from "../dispatchProjection.js";
|
|
2
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentRefusal } from "../contracts/index.js";
|
|
2
3
|
import type { OmnipackWebhookEvidence } from "./mappers.js";
|
|
3
4
|
import { type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
4
5
|
export declare const refusal: (reason: FulfillmentRefusal["reason"], phase: FulfillmentRefusal["phase"], retryable?: boolean) => FulfillmentRefusal;
|
|
5
6
|
export declare const digest: (bytes: string | Uint8Array) => string;
|
|
6
|
-
/**
|
|
7
|
-
export declare function
|
|
7
|
+
/** Share admission with the durable sink, including explicit deny allowlists. */
|
|
8
|
+
export declare function correlationReference(value: string | null | undefined, codecs?: OmnipackObservationCodecs): string | null;
|
|
9
|
+
export interface OmnipackObservationCodecs extends FulfillmentObservationCodecs {
|
|
10
|
+
normalizeCarrier?(input: {
|
|
11
|
+
carrierRef: string | null;
|
|
12
|
+
serviceRef: string | null;
|
|
13
|
+
}): string | null;
|
|
14
|
+
}
|
|
8
15
|
export declare function timestamp(value: string | null): string | null;
|
|
9
16
|
export declare function eventKind(value: string): OmnipackWebhookRouteEvent | null;
|
|
10
|
-
export declare function publicUrl(value: string | null, approve: (url: string) => boolean): string | null;
|
|
11
17
|
export declare function observation(input: {
|
|
12
18
|
evidence: OmnipackWebhookEvidence;
|
|
13
19
|
event: FulfillmentObservation["eventKind"];
|
|
@@ -18,4 +24,5 @@ export declare function observation(input: {
|
|
|
18
24
|
source: "provider_webhook" | "provider_reconciliation";
|
|
19
25
|
fulfillmentRef?: string;
|
|
20
26
|
approveTrackingUrl: (url: string) => boolean;
|
|
27
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
21
28
|
}): FulfillmentObservation;
|
|
@@ -1,12 +1,15 @@
|
|
|
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 { EVENT_TO_STATUS } from "./omnipackWebhookPayload.js";
|
|
6
|
+
import { buildProviderEventId, EVENT_TO_STATUS } from "./omnipackWebhookPayload.js";
|
|
4
7
|
import { localStatusFor } from "./omnipackStatusVocabulary.js";
|
|
5
8
|
export const refusal = (reason, phase, retryable = false) => ({ reason, phase, retryable });
|
|
6
9
|
export const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
7
|
-
/**
|
|
8
|
-
export function
|
|
9
|
-
return
|
|
10
|
+
/** Share admission with the durable sink, including explicit deny allowlists. */
|
|
11
|
+
export function correlationReference(value, codecs = {}) {
|
|
12
|
+
return admittedReference(value, codecs.approveReference);
|
|
10
13
|
}
|
|
11
14
|
export function timestamp(value) {
|
|
12
15
|
return value && /^\d{4}-\d\d-\d\dT/u.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
|
|
@@ -14,44 +17,56 @@ export function timestamp(value) {
|
|
|
14
17
|
export function eventKind(value) {
|
|
15
18
|
return Object.hasOwn(EVENT_TO_STATUS, value) ? value : null;
|
|
16
19
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
function tracking(refs, codecs, approve) {
|
|
21
|
+
let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
|
|
22
|
+
const trackingRefs = [];
|
|
23
|
+
for (const item of refs) {
|
|
24
|
+
if (item.service !== null && !correlationReference(item.service, codecs))
|
|
25
|
+
rejectedServices++;
|
|
26
|
+
if (item.trackingUrl !== null && !publicTrackingUrl(item.trackingUrl, approve, codecs.trackingUrl))
|
|
27
|
+
rejectedTrackingUrls++;
|
|
28
|
+
const trackingNumber = correlationReference(item.trackingNumber, codecs);
|
|
29
|
+
if (!trackingNumber) {
|
|
30
|
+
rejectedTrackingRefs++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// Service admission precedes any derivation. A refused service cannot leak a prefix.
|
|
34
|
+
const serviceRef = correlationReference(item.service, codecs);
|
|
35
|
+
const carrierRef = item.service !== null && !serviceRef ? null : reference(codecs.normalizeCarrier
|
|
36
|
+
? codecs.normalizeCarrier({ carrierRef: item.carrier, serviceRef })
|
|
37
|
+
: serviceRef?.split("_")[0]?.toLowerCase());
|
|
38
|
+
trackingRefs.push({ trackingNumber, carrierRef, serviceRef: item.service, publicUrl: item.trackingUrl });
|
|
24
39
|
}
|
|
25
|
-
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
function tracking(refs, approve) {
|
|
30
|
-
return refs.flatMap(item => {
|
|
31
|
-
const trackingNumber = reference(item.trackingNumber);
|
|
32
|
-
return trackingNumber ? [{ trackingNumber, carrierRef: reference(item.carrier), serviceRef: reference(item.service), publicUrl: publicUrl(item.trackingUrl, approve) }] : [];
|
|
33
|
-
});
|
|
40
|
+
return { trackingRefs, rejectedTrackingRefs, rejectedServices, rejectedTrackingUrls };
|
|
34
41
|
}
|
|
35
42
|
export function observation(input) {
|
|
36
43
|
const evidence = input.evidence;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
44
|
+
const codecs = input.observationCodecs ?? {};
|
|
45
|
+
const externalRef = correlationReference(evidence.providerOrderId, codecs);
|
|
46
|
+
const providerFulfillmentRef = correlationReference(evidence.fulfilmentNumber, codecs);
|
|
47
|
+
const orderNumber = correlationReference(evidence.orderNumber, codecs);
|
|
40
48
|
const fulfillmentRef = reference(input.fulfillmentRef) ?? providerFulfillmentRef ?? externalRef ?? orderNumber;
|
|
41
|
-
if (!fulfillmentRef)
|
|
49
|
+
if (!fulfillmentRef || input.source === "provider_webhook" && [evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => value !== null && !correlationReference(value, codecs)))
|
|
42
50
|
throw refusal("missing_correlation", "normalize");
|
|
43
51
|
const occurredAt = timestamp(evidence.occurredAt);
|
|
44
52
|
if (evidence.occurredAt && !occurredAt)
|
|
45
53
|
throw refusal("malformed", "normalize");
|
|
46
|
-
|
|
54
|
+
// Compose the key with the provider's
|
|
55
|
+
// original bytes, including minute precision and offsets, rather than the UTC fact.
|
|
56
|
+
const key = buildProviderEventId(evidence);
|
|
57
|
+
const normalizedTracking = tracking(evidence.trackingReferences, codecs, input.approveTrackingUrl);
|
|
58
|
+
const rejectedTrackingRefs = normalizedTracking.rejectedTrackingRefs + (evidence.rejectedTrackingRefs ?? 0)
|
|
59
|
+
+ (evidence.rejectedServices ?? normalizedTracking.rejectedServices);
|
|
47
60
|
const status = input.status ?? localStatusFor(EVENT_TO_STATUS[input.event]);
|
|
48
61
|
const fact = parseNormalizedFulfillmentFact({
|
|
49
|
-
schemaVersion: 1, mapperVersion: "1", factId: key, providerKind: "omnipack", fulfillmentReference: fulfillmentRef,
|
|
62
|
+
schemaVersion: 1, mapperVersion: "1", factId: digest(key), providerKind: "omnipack", fulfillmentReference: reference(fulfillmentRef) ?? digest(fulfillmentRef),
|
|
50
63
|
canonicalStatus: status, provenance: { source: input.source, authority: "provider_asserted" },
|
|
51
64
|
occurredAt, receivedAt: input.receivedAt,
|
|
52
65
|
rawEvent: { reference: input.evidenceRef, digestAlgorithm: "sha256", digest: input.rawDigest },
|
|
53
66
|
}, { isCanonicalStatus: (value) => ["provider_received", "picking", "packed", "in_transit", "delivered", "exception", "cancelled"].includes(value) });
|
|
54
|
-
return { fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
55
|
-
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef, orderNumber, providerFulfillmentRef, parcelRef: null },
|
|
56
|
-
trackingRefs:
|
|
67
|
+
return projectObservation({ fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
68
|
+
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef: evidence.providerOrderId, orderNumber: evidence.orderNumber, providerFulfillmentRef: evidence.fulfilmentNumber, parcelRef: null },
|
|
69
|
+
trackingRefs: normalizedTracking.trackingRefs, diagnostics: { admission: {
|
|
70
|
+
rejectedTrackingRefs, rejectedTrackingUrls: evidence.rejectedTrackingUrls ?? normalizedTracking.rejectedTrackingUrls,
|
|
71
|
+
} } }, input.approveTrackingUrl, codecs);
|
|
57
72
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FulfillmentObservationPagePort, FulfillmentReconciliationObservation } from "../contracts/index.js";
|
|
2
|
+
import type { OmnipackClient } from "./client.js";
|
|
3
|
+
import type { OmnipackFulfilmentEvidence } from "./mappers.js";
|
|
4
|
+
import { refusal, type OmnipackObservationCodecs } from "./observation.js";
|
|
5
|
+
type Config = {
|
|
6
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
7
|
+
clock(): string;
|
|
8
|
+
mintEvidenceReference(): `evidence:${string}`;
|
|
9
|
+
approveTrackingUrl(url: string): boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function reconciliationObservation(row: OmnipackFulfilmentEvidence, config: Config, fulfillmentRef?: string): Extract<FulfillmentReconciliationObservation, {
|
|
12
|
+
kind: "found" | "unmapped";
|
|
13
|
+
}>;
|
|
14
|
+
export declare function createReconciliationPagePort(client: OmnipackClient, config: Config, ready: () => ReturnType<typeof refusal> | null): FulfillmentObservationPagePort;
|
|
15
|
+
export {};
|