@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
|
@@ -19,13 +19,24 @@ export interface SyncCursor {
|
|
|
19
19
|
/** G15: successful sellable coverage gaps; legacy absent values read as []. */
|
|
20
20
|
coverageGapSkus?: readonly string[];
|
|
21
21
|
refusal: FulfillmentRefusal | null;
|
|
22
|
+
/** Per-entry bounded diagnostics and run evidence in the existing cursor JSON, never raw errors. */
|
|
23
|
+
syncRunId?: string;
|
|
24
|
+
counts?: { checked: number; updated: number; movements: number; rejected: number; failures: number; providerCalls: number; readBacks: number };
|
|
25
|
+
items?: readonly { kind?: "stock" | "movement"; itemIndex: number; skuRef: string | null; status: "recorded" | "failed" | "rejected"; reason?: string;
|
|
26
|
+
activeReserved?: number; sellableNow?: number | null; coverageGapQuantity?: number }[];
|
|
27
|
+
alerts?: readonly { skuRef: string; thresholdKind: Exclude<Threshold, "forecast">; severity: "info" | "warning" | "critical";
|
|
28
|
+
providerForSaleQuantity: number; localAvailableQuantity: number; detectedAt: string;
|
|
29
|
+
activeReserved?: number; coverageGapQuantity?: number; gapHours?: number; thresholdHours?: number }[];
|
|
30
|
+
staleSync?: { gapHours: number; thresholdHours: number; detectedAt: string };
|
|
31
|
+
error?: { reason: string; providerError?: { operation: "get_stock" | "get_stock_movements"; status: number | null; code: string; retryable: boolean } } | null;
|
|
22
32
|
}
|
|
23
33
|
export interface LocalStock { skuRef: string; onHand: number; reserved: number; unavailable: number; safetyStock: number }
|
|
24
34
|
type Threshold = "safety_stock" | "reservation_coverage" | "forecast" | "stale_sync";
|
|
25
35
|
type Conflict = "unknown_sku" | "ean_conflict" | "kind_quantity_conflict";
|
|
26
36
|
export interface FulfillmentStockSyncProvider {
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
/** Rejected rows carry only their input index and a closed reason, never an unsafe reference. */
|
|
38
|
+
getStock(): Promise<(StockSnapshot | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
|
|
39
|
+
getStockMovements(input?: { startLocalDate?: string; endLocalDate?: string; size?: number }): Promise<(StockMovement | { kind: "rejected"; itemIndex: number; reason: "invalid_sku_ref" })[]>;
|
|
29
40
|
}
|
|
30
41
|
export interface FulfillmentStockSyncPort {
|
|
31
42
|
readCursor(): Promise<SyncCursor | null>;
|
|
@@ -1,18 +1,105 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentOperationError } from "./contracts/index.js";
|
|
2
3
|
|
|
4
|
+
/** Machine identities only: arbitrary exception prose, URLs and credentials are omitted. */
|
|
5
|
+
export function safeDiagnosticCode(value: unknown): string | null {
|
|
6
|
+
return reference(value) && typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_]{0,95}$/u.test(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
export function localFailureReason(stage: string, error: unknown, privacy = durableStrings()): string {
|
|
9
|
+
if (!error || typeof error !== "object") return stage;
|
|
10
|
+
const fields = error as { code?: unknown; causeCode?: unknown; causeMessage?: unknown; message?: unknown };
|
|
11
|
+
const diagnostic = (value: unknown) => typeof value === "string" ? privacy.token(value, safeDiagnosticCode) : null;
|
|
12
|
+
const ownCode = diagnostic(fields.code), causeCode = diagnostic(fields.causeCode), causeMessage = diagnostic(fields.causeMessage);
|
|
13
|
+
const messagePattern = /^([A-Za-z][A-Za-z0-9_]*)(?::([A-Z0-9]{5}))?$/u;
|
|
14
|
+
const safeMessage = typeof fields.message === "string" ? privacy.token(fields.message, value => messagePattern.test(value) ? value : null) : null;
|
|
15
|
+
const message = safeMessage?.match(messagePattern);
|
|
16
|
+
const cause = causeMessage ?? (message && (message[2] || /_(?:failed|down|missing|invalid|conflict|not_found)$/u.test(message[1]!)) ? safeDiagnosticCode(message[1]) : null);
|
|
17
|
+
const code = causeCode ?? ownCode ?? message?.[2];
|
|
18
|
+
return [stage, cause, code].filter(Boolean).join(":");
|
|
19
|
+
}
|
|
20
|
+
export function operationError(error: Omit<FulfillmentOperationError, "cause"> & { cause?: unknown }): FulfillmentOperationError {
|
|
21
|
+
const privacy = durableStrings(error.redactedFieldCount);
|
|
22
|
+
const code = privacy.token(error.code, safeDiagnosticCode) ?? "operation_failed";
|
|
23
|
+
const cause = typeof error.cause === "string" ? privacy.token(error.cause,
|
|
24
|
+
value => /^ack(?::[A-Za-z0-9_]{1,96}){0,2}$/u.test(value) ? value.slice(0,180) : null) : null;
|
|
25
|
+
return { code, status: Number.isInteger(error.status) && error.status! >= 100 && error.status! <= 599 ? error.status : null,
|
|
26
|
+
retryable: error.retryable === true, mayHaveSucceeded: error.mayHaveSucceeded === true,
|
|
27
|
+
...(typeof error.submissionAttempted === "boolean" ? { submissionAttempted: error.submissionAttempted } : {}),
|
|
28
|
+
...(typeof error.responseBodyRead === "boolean" ? { responseBodyRead: error.responseBodyRead } : {}),
|
|
29
|
+
...(cause ? { cause } : {}), ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}) };
|
|
30
|
+
}
|
|
31
|
+
// The fulfillment opaque-ID rule; never accept free-form metadata as a reference.
|
|
32
|
+
export const reference = (value: unknown): string | null => typeof value === "string"
|
|
33
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) ? value : null;
|
|
34
|
+
/** Opaque by default; an explicit allowlist can admit wider incumbent references. */
|
|
35
|
+
export function admittedReference(value: string | null | undefined, approve?: (value: string) => boolean): string | null {
|
|
36
|
+
if (typeof value !== "string" || /[\p{Cc}\p{Cf}]/u.test(value)) return null;
|
|
37
|
+
const admitted = approve ? approve(value) : reference(value) !== null;
|
|
38
|
+
const text = value.trim();
|
|
39
|
+
return admitted && text && text.length <= 256 && !/[@]|[a-z][a-z0-9+.-]*:\/\//iu.test(text) ? text : null;
|
|
40
|
+
}
|
|
41
|
+
const isoDate = (value: string | null | undefined): string | null => typeof value === "string"
|
|
42
|
+
&& /^\d{4}-\d\d-\d\d$/u.test(value) && Number.isFinite(Date.parse(value))
|
|
43
|
+
&& new Date(value).toISOString().slice(0, 10) === value ? value : null;
|
|
44
|
+
/** One counted sanitizer for durable projections; codecs are explicit host policy. */
|
|
45
|
+
export function durableStrings(priorCount = 0) {
|
|
46
|
+
let count = Number.isSafeInteger(priorCount) && priorCount > 0 ? priorCount : 0;
|
|
47
|
+
return {
|
|
48
|
+
token(value: string | null | undefined, codec: (value: string) => string | null = reference) {
|
|
49
|
+
if (value == null) return null;
|
|
50
|
+
const safe = codec(value);
|
|
51
|
+
if (safe === null) count += 1;
|
|
52
|
+
return safe;
|
|
53
|
+
},
|
|
54
|
+
get redactedFieldCount() { return count; },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
3
57
|
/** Copy only the durable allowlist, including when an adapter object has extra runtime properties. */
|
|
4
58
|
export function dispatchEvidence(evidence: DispatchEvidence): DispatchEvidence {
|
|
59
|
+
const privacy = durableStrings(evidence.redactedFieldCount ?? evidence.transcript?.redactedFieldCount);
|
|
60
|
+
const sanitize = privacy.token;
|
|
61
|
+
const commandFingerprint = evidence.commandFingerprint;
|
|
62
|
+
const source = evidence.contactRevision.source;
|
|
63
|
+
const providerRef = sanitize(evidence.providerRef) ?? "redacted", carrierRef = sanitize(evidence.carrierRef), serviceRef = sanitize(evidence.serviceRef);
|
|
64
|
+
const mode = evidence.mapping ? (sanitize(evidence.mapping.mode) ?? "shadow") as NonNullable<DispatchEvidence["mapping"]>["mode"] : null;
|
|
65
|
+
const transcript = evidence.transcript ? {
|
|
66
|
+
itemCount: evidence.transcript.items.length,
|
|
67
|
+
items: evidence.transcript.items.map(item => ({ skuRef: sanitize(item.skuRef) ?? "redacted", quantity: Number.isSafeInteger(item.quantity) && item.quantity > 0 ? item.quantity : 0,
|
|
68
|
+
lotRef: sanitize(item.lotRef), expirationDate: sanitize(item.expirationDate, isoDate) })),
|
|
69
|
+
deliveryKind: evidence.transcript.deliveryKind === "parcel-locker" ? "parcel-locker" as const : "courier" as const,
|
|
70
|
+
pickupPointRef: sanitize(evidence.transcript.pickupPointRef),
|
|
71
|
+
...(privacy.redactedFieldCount > 0 ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
|
|
72
|
+
} : null;
|
|
5
73
|
return {
|
|
6
|
-
commandFingerprint
|
|
7
|
-
contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source
|
|
8
|
-
providerRef
|
|
74
|
+
commandFingerprint,
|
|
75
|
+
contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source, revision: evidence.contactRevision.revision },
|
|
76
|
+
providerRef, carrierRef, serviceRef,
|
|
77
|
+
...(transcript ? { transcript } : privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
|
|
78
|
+
...(evidence.mapping ? { mapping: { source: "dispatch_worker" as const, mode: mode!, shadowMode: evidence.mapping.mode === "shadow" } } : {}),
|
|
9
79
|
};
|
|
10
80
|
}
|
|
81
|
+
/** Shadow has a local command identity; it never invokes a provider or resolves credentials. */
|
|
82
|
+
export function shadowDispatchEvidence(request: FulfillmentDispatchRequest, commandFingerprint?: string): DispatchEvidence {
|
|
83
|
+
if (!request.selection.providerRef || !request.lines.length || request.lines.some(line => !Number.isSafeInteger(line.quantity) || line.quantity <= 0)) throw new Error("dispatch_shadow_command_invalid");
|
|
84
|
+
return dispatchEvidence({ commandFingerprint: commandFingerprint ?? createHash("sha256").update(JSON.stringify(request)).digest("hex"),
|
|
85
|
+
contactRevision: request.contactRevision, providerRef: request.selection.providerRef,
|
|
86
|
+
carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
|
|
87
|
+
transcript: { itemCount: request.lines.length, items: request.lines.map(line => ({ skuRef: line.skuRef, quantity: line.quantity,
|
|
88
|
+
lotRef: line.lotRef ?? null, expirationDate: line.expirationDate ?? null })),
|
|
89
|
+
deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
|
|
90
|
+
}
|
|
11
91
|
export function dispatchResult(result: FulfillmentDispatchResult): FulfillmentDispatchResult {
|
|
92
|
+
const privacy = durableStrings(result.redactedFieldCount);
|
|
93
|
+
const externalRef = result.kind === "accepted" ? privacy.token(result.externalRef) ?? "redacted" : null;
|
|
94
|
+
const refusal = result.kind !== "accepted" ? { reason: (privacy.token(result.refusal.reason) ?? "malformed") as typeof result.refusal.reason,
|
|
95
|
+
phase: (privacy.token(result.refusal.phase) ?? "normalize") as typeof result.refusal.phase, retryable: result.refusal.retryable } : null;
|
|
96
|
+
const redactions = privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
|
|
97
|
+
const error = result.error ? { error: operationError({ ...result.error, mayHaveSucceeded: result.kind !== "not_dispatched" }) } : {};
|
|
12
98
|
switch (result.kind) {
|
|
13
|
-
case "accepted": return { kind: "accepted", externalRef:
|
|
14
|
-
|
|
15
|
-
case "
|
|
99
|
+
case "accepted": return { kind: "accepted", externalRef: externalRef!, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, ...redactions, evidence: dispatchEvidence(result.evidence), ...error,
|
|
100
|
+
...(result.recovery ? { recovery: { source: "persisted_acceptance", proof: "persisted_external_ref", noProviderPost: true } } : {}) };
|
|
101
|
+
case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal!, ...redactions, ...error };
|
|
102
|
+
case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: refusal!, ...redactions, ...error };
|
|
16
103
|
}
|
|
17
104
|
}
|
|
18
105
|
export function acceptedProviderProof(record: DispatchRecord | null): record is DispatchRecord & { externalRef: string } {
|
package/src/fulfillment/index.ts
CHANGED
|
@@ -5,9 +5,10 @@ export { createOutboxOrderPaidFulfillmentHandler } from "./paidHandoff.js";
|
|
|
5
5
|
export { collectPaidFulfillmentRecoveryCandidates } from "./paidFulfillmentRecovery.js";
|
|
6
6
|
export { runFulfillmentDispatchWorker } from "./providerDispatchWorker.js";
|
|
7
7
|
export { createFulfillmentCommandLedger } from "./providerCommandLedger.js";
|
|
8
|
-
export {
|
|
8
|
+
export type { FulfillmentIngressCounts, FulfillmentIngressResult } from "./verifiedIngress.js";
|
|
9
|
+
export { verifyAndNormalizeFulfillmentObservation, fulfillmentIngressHttpResult } from "./verifiedIngress.js";
|
|
9
10
|
export { applyFulfillmentObservationEffects } from "./fulfillmentObservationEffects.js";
|
|
10
|
-
export { reconcileFulfillmentObservations } from "./providerReconciliationWorker.js";
|
|
11
|
+
export { reconcileFulfillmentObservations, runFulfillmentReconciliationPage } from "./providerReconciliationWorker.js";
|
|
11
12
|
export { runFulfillmentStockSyncWorker } from "./providerStockSyncWorker.js";
|
|
12
13
|
export { runFulfillmentProductSyncWorker } from "./providerProductSyncWorker.js";
|
|
13
14
|
export { createFulfillmentLowStockEvidenceHandler } from "./lowStockEvidenceHandler.js";
|
|
@@ -5,4 +5,5 @@ export const FULFILLMENT_MIGRATIONS = [
|
|
|
5
5
|
{ position: 51, catalogPath: "db/platform/migrations/20260819150000_fulfillment_shipment_sequence.sql", sha256: "906706cf66a6a555aa1219b12eb674d937eaa099abde6da18766310af43aaa9f", url: new URL("../../sql/20260819150000_fulfillment_shipment_sequence.sql", import.meta.url) },
|
|
6
6
|
{ position: 73, catalogPath: "db/platform/migrations/20261022080000_fulfillment_command_ledger.sql", sha256: "c6806906cdb0b6931b29f7a5c7c800016e98d04e6ea956f3c1e219a6cdb7e9d1", url: new URL("../../sql/20261022080000_fulfillment_command_ledger.sql", import.meta.url) },
|
|
7
7
|
{ position: 74, catalogPath: "db/platform/migrations/20261022090000_fulfillment_delivery_alignment.sql", sha256: "d9966ae79fe77a0feae8317dc5b46ef2d8196dd0790b1cd0ee1b06ab58061499", url: new URL("../../sql/20261022090000_fulfillment_delivery_alignment.sql", import.meta.url) },
|
|
8
|
+
{ position: 76, catalogPath: "db/platform/migrations/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", sha256: "6d132dec2820047f4e337bcf752b3d884600ccc12de03887fe6d6aa5575bc1db", url: new URL("../../sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql", import.meta.url) },
|
|
8
9
|
] as const;
|
|
@@ -1,32 +1,83 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
3
|
import { parseFulfillmentFact } from "./normalizedFulfillmentFacts.js";
|
|
4
|
+
import { admittedReference, durableStrings, reference } from "./dispatchProjection.js";
|
|
3
5
|
import { FULFILLMENT_STATUS_MAP } from "./statusMap.js";
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
type Privacy = ReturnType<typeof durableStrings>;
|
|
8
|
+
const timestamp = z.iso.datetime({ offset: true });
|
|
9
|
+
export function projectCorrelation(value: FulfillmentObservation["correlation"], privacy = durableStrings(), codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation["correlation"] {
|
|
10
|
+
const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
|
|
11
|
+
return { orderRef: value.orderRef, fulfillmentRef: value.fulfillmentRef, externalRef: token(value.externalRef),
|
|
12
|
+
orderNumber: token(value.orderNumber), providerFulfillmentRef: token(value.providerFulfillmentRef), parcelRef: value.parcelRef };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function projectProviderStatus(value: NonNullable<FulfillmentObservation["providerStatus"]>, privacy = durableStrings()): NonNullable<FulfillmentObservation["providerStatus"]> {
|
|
16
|
+
return { primary: privacy.token(value.primary), subStatus: privacy.token(value.subStatus) };
|
|
17
|
+
}
|
|
18
|
+
export type PollEvidence = Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics">;
|
|
19
|
+
function pollEvidence(value: PollEvidence, privacy: Privacy, codecs: FulfillmentObservationCodecs = {}): PollEvidence {
|
|
20
|
+
// Host identities and keys composed from admitted parts are already authoritative.
|
|
21
|
+
return { eventKey: value.eventKey, correlation: projectCorrelation(value.correlation, privacy, codecs),
|
|
22
|
+
...(value.providerStatus ? { providerStatus: projectProviderStatus(value.providerStatus, privacy) } : {}) };
|
|
23
|
+
}
|
|
24
|
+
/** Unknown-status quarantine/ACK evidence has the same privacy boundary as observations. */
|
|
25
|
+
export function projectPollEvidence(value: PollEvidence): PollEvidence {
|
|
26
|
+
const privacy = durableStrings(value.diagnostics?.redactedFieldCount);
|
|
27
|
+
const safe = pollEvidence(value, privacy);
|
|
28
|
+
return { ...safe, ...(privacy.redactedFieldCount ? { diagnostics: { redactedFieldCount: privacy.redactedFieldCount } } : {}) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One URL policy: strict default, optional adopter codec, and approval at every sink. */
|
|
32
|
+
export function publicTrackingUrl(value: string, approve?: (url: string) => boolean, codec?: (url: string) => string | null): string | null {
|
|
33
|
+
try {
|
|
34
|
+
const candidate = codec ? codec(value) : value;
|
|
35
|
+
if (!candidate) return null;
|
|
36
|
+
const url = new URL(candidate);
|
|
37
|
+
return url.protocol === "https:" && !url.username && !url.password && (codec || (!url.search && !url.hash)) && approve?.(url.href) ? url.href : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
export function projectTrackingReference(value: FulfillmentObservation["trackingRefs"][number], privacy = durableStrings(), approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}) {
|
|
41
|
+
const token = (value: string | null) => privacy.token(value, text => admittedReference(text, codecs.approveReference));
|
|
42
|
+
const trackingNumber = token(value.trackingNumber);
|
|
43
|
+
if (trackingNumber === null) return null;
|
|
44
|
+
const serviceRef = token(value.serviceRef);
|
|
45
|
+
// Even an opaque-looking derived carrier is unsafe when its service was refused.
|
|
46
|
+
const carrierRef = privacy.token(value.carrierRef, text => value.serviceRef !== null && serviceRef === null ? null : reference(text));
|
|
47
|
+
return { trackingNumber, carrierRef, serviceRef,
|
|
48
|
+
publicUrl: privacy.token(value.publicUrl, url => publicTrackingUrl(url, approveTrackingUrl, codecs.trackingUrl)) };
|
|
8
49
|
}
|
|
9
50
|
|
|
10
51
|
/** New boundary projection: arbitrary adapter properties are never durable. */
|
|
11
|
-
export function projectObservation(value: FulfillmentObservation): FulfillmentObservation {
|
|
52
|
+
export function projectObservation(value: FulfillmentObservation, approveTrackingUrl?: (url: string) => boolean, codecs: FulfillmentObservationCodecs = {}): FulfillmentObservation {
|
|
53
|
+
const privacy = durableStrings(value.diagnostics?.redactedFieldCount), token = privacy.token;
|
|
12
54
|
const diagnostics: NonNullable<FulfillmentObservation["diagnostics"]> = {};
|
|
55
|
+
if (value.diagnostics?.admission) {
|
|
56
|
+
const count = (value: number) => Number.isSafeInteger(value) && value > 0 ? value : 0;
|
|
57
|
+
diagnostics.admission = { rejectedTrackingRefs: count(value.diagnostics.admission.rejectedTrackingRefs), rejectedTrackingUrls: count(value.diagnostics.admission.rejectedTrackingUrls) };
|
|
58
|
+
}
|
|
13
59
|
if (value.diagnostics?.refusal) {
|
|
14
60
|
const { reason, phase, retryable } = value.diagnostics.refusal;
|
|
15
|
-
diagnostics.refusal = { reason, phase, retryable };
|
|
61
|
+
diagnostics.refusal = { reason: token(reason) as typeof reason, phase: token(phase) as typeof phase, retryable };
|
|
16
62
|
}
|
|
17
63
|
if (value.diagnostics?.stockContention) diagnostics.stockContention = { sqlState: "55P03", phase: "provider_stock_consumed", retryable: true };
|
|
18
|
-
if (value.diagnostics?.annotation) diagnostics.annotation = value.diagnostics.annotation;
|
|
64
|
+
if (value.diagnostics?.annotation) diagnostics.annotation = token(value.diagnostics.annotation) as typeof value.diagnostics.annotation;
|
|
19
65
|
if (value.diagnostics?.recoveredPickedReceipt) {
|
|
20
66
|
const { receiptRef, closed } = value.diagnostics.recoveredPickedReceipt;
|
|
21
67
|
diagnostics.recoveredPickedReceipt = { receiptRef, closed };
|
|
22
68
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
69
|
+
const fact = parseFulfillmentFact(value.fact);
|
|
70
|
+
const safe = {
|
|
71
|
+
...pollEvidence(value, privacy, codecs),
|
|
72
|
+
fact: { ...fact, mapperVersion: token(fact.mapperVersion) ?? "redacted" }, eventKind: (token(value.eventKind) ?? "redacted") as FulfillmentObservation["eventKind"],
|
|
73
|
+
firstReceivedAt: timestamp.parse(value.firstReceivedAt),
|
|
74
|
+
trackingRefs: value.trackingRefs.flatMap(ref => {
|
|
75
|
+
const projected = projectTrackingReference(ref, privacy, approveTrackingUrl, codecs);
|
|
76
|
+
return projected ? [projected] : [];
|
|
77
|
+
}),
|
|
29
78
|
};
|
|
79
|
+
if (privacy.redactedFieldCount) diagnostics.redactedFieldCount = privacy.redactedFieldCount;
|
|
80
|
+
return { ...safe, ...(Object.keys(diagnostics).length ? { diagnostics } : {}) };
|
|
30
81
|
}
|
|
31
82
|
|
|
32
83
|
const HANDOVER_STATUSES: ReadonlySet<string> = new Set(
|
|
@@ -45,7 +45,7 @@ export interface OmnipackClient {
|
|
|
45
45
|
createProduct(payload: Record<string, unknown>, sku: string): Promise<OmnipackProductCreatedEvidence>;
|
|
46
46
|
getProduct(sku: string): Promise<OmnipackProductEvidence>;
|
|
47
47
|
createProductPack(sku: string, payload: Record<string, unknown>, ean: string): Promise<OmnipackProductPackCreatedEvidence>;
|
|
48
|
-
getStock(): Promise<OmnipackStockEvidence[]>;
|
|
48
|
+
getStock(options?: { refuseRedirectRetry: boolean }): Promise<OmnipackStockEvidence[]>;
|
|
49
49
|
getStockMovements(params?: OmnipackQueryParams): Promise<OmnipackStockMovementEvidence[]>;
|
|
50
50
|
getFulfilmentsPage(params?: OmnipackQueryParams): Promise<{ rows: OmnipackFulfilmentEvidence[]; complete: boolean }>;
|
|
51
51
|
getFulfilments(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentEvidence[]>;
|
|
@@ -87,8 +87,8 @@ export function createOmnipackClient(
|
|
|
87
87
|
return mapOmnipackProductPackCreatedResponse(body, sku, ean);
|
|
88
88
|
},
|
|
89
89
|
|
|
90
|
-
async getStock() {
|
|
91
|
-
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
|
|
90
|
+
async getStock(options) {
|
|
91
|
+
return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }, options?.refuseRedirectRetry));
|
|
92
92
|
},
|
|
93
93
|
|
|
94
94
|
async getStockMovements(params) {
|
|
@@ -98,6 +98,7 @@ export function createOmnipackClient(
|
|
|
98
98
|
"getStockMovements",
|
|
99
99
|
withQuery("/stock-movements", params),
|
|
100
100
|
{ method: "GET" },
|
|
101
|
+
true,
|
|
101
102
|
));
|
|
102
103
|
},
|
|
103
104
|
|
|
@@ -141,43 +142,54 @@ async function requestJson(
|
|
|
141
142
|
operation: string,
|
|
142
143
|
path: string,
|
|
143
144
|
init: RequestInit,
|
|
145
|
+
refuseRedirectRetry = false,
|
|
144
146
|
): Promise<unknown> {
|
|
145
147
|
const method = (init.method ?? "GET").toUpperCase();
|
|
146
148
|
const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
|
|
147
|
-
|
|
149
|
+
// Dispatch/readback diagnostics must not change product-sync or stock retry policy.
|
|
150
|
+
const diagnoseRedirectFailure = refuseRedirectRetry || operation === "createOrder"
|
|
151
|
+
|| operation === "getFulfilments" || operation === "getFulfilmentRequests";
|
|
152
|
+
let lastError: OmnipackProviderError | null = null;
|
|
148
153
|
|
|
149
154
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
150
155
|
let submitted = false;
|
|
156
|
+
let responseBodyRead: boolean | undefined;
|
|
157
|
+
const dispatchFacts = () => operation === "createOrder" ? { submissionAttempted: submitted,
|
|
158
|
+
...(responseBodyRead !== undefined ? { responseBodyRead } : {}) } : {};
|
|
151
159
|
try {
|
|
152
160
|
const base = new URL(config.baseUrl);
|
|
153
161
|
const url = new URL(path, base);
|
|
154
162
|
if (url.origin !== base.origin || url.username || url.password || url.protocol !== "https:") throw new Error("Invalid fulfillment path");
|
|
155
163
|
const response = await fetchWithTimeout(fetchImpl, url, config, init, () => { submitted = true; });
|
|
156
|
-
|
|
164
|
+
responseBodyRead = false;
|
|
165
|
+
const body = await readJsonBody(response).then(value => { responseBodyRead = true; return value; }).catch(error => {
|
|
166
|
+
if (response.ok) throw error;
|
|
167
|
+
return null;
|
|
168
|
+
});
|
|
169
|
+
if (response.ok) return body;
|
|
157
170
|
|
|
158
171
|
const retryable = method === "GET" && isRetryableStatus(response.status);
|
|
159
172
|
// A failed body read cannot erase the rejection status already received.
|
|
160
|
-
|
|
161
|
-
lastError = new OmnipackProviderError({
|
|
173
|
+
lastError = Object.assign(new OmnipackProviderError({
|
|
162
174
|
operation,
|
|
163
175
|
status: response.status,
|
|
164
|
-
code: readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
176
|
+
code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
|
|
165
177
|
retryable,
|
|
166
178
|
mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
|
|
167
|
-
});
|
|
179
|
+
}), dispatchFacts());
|
|
168
180
|
if (!retryable || attempt === attempts) throw lastError;
|
|
169
181
|
} catch (error) {
|
|
170
182
|
const retryable = method === "GET" && attempt < attempts;
|
|
171
183
|
lastError = submitted && error instanceof OmnipackProviderError
|
|
172
184
|
? error
|
|
173
|
-
: new OmnipackProviderError({
|
|
185
|
+
: Object.assign(new OmnipackProviderError({
|
|
174
186
|
operation,
|
|
175
187
|
status: null,
|
|
176
|
-
code: "network_error",
|
|
188
|
+
code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
|
|
177
189
|
retryable,
|
|
178
190
|
mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
|
|
179
|
-
});
|
|
180
|
-
if (!retryable) throw lastError;
|
|
191
|
+
}), dispatchFacts());
|
|
192
|
+
if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused")) throw lastError;
|
|
181
193
|
}
|
|
182
194
|
}
|
|
183
195
|
|
|
@@ -256,3 +268,10 @@ function readProviderErrorCode(body: unknown): string | null {
|
|
|
256
268
|
function isRetryableStatus(status: number): boolean {
|
|
257
269
|
return status === 408 || status === 429 || status >= 500;
|
|
258
270
|
}
|
|
271
|
+
|
|
272
|
+
// Fetch implementations expose redirect refusal as an exception, sometimes through cause.
|
|
273
|
+
// Inspect it only to select a closed diagnostic; no exception text leaves this adapter.
|
|
274
|
+
function isRedirectRefusal(error: unknown): boolean {
|
|
275
|
+
if (!(error instanceof Error)) return false;
|
|
276
|
+
return /redirect/i.test(error.message) || (error.cause instanceof Error && /redirect/i.test(error.cause.message));
|
|
277
|
+
}
|