@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +222 -99
  2. package/README.md +59 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +95 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +17 -7
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +104 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +47 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/capabilities.d.ts +11 -0
  46. package/dist/invoicing/capabilities.js +10 -0
  47. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  48. package/dist/invoicing/contracts/capabilities.js +1 -0
  49. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  50. package/dist/invoicing/contracts/companyLookup.js +1 -0
  51. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  52. package/dist/invoicing/contracts/delivery.js +1 -0
  53. package/dist/invoicing/contracts/facts.d.ts +100 -0
  54. package/dist/invoicing/contracts/facts.js +1 -0
  55. package/dist/invoicing/contracts/host.d.ts +246 -0
  56. package/dist/invoicing/contracts/host.js +1 -0
  57. package/dist/invoicing/contracts/index.d.ts +16 -0
  58. package/dist/invoicing/contracts/index.js +1 -0
  59. package/dist/invoicing/contracts/provider.d.ts +126 -0
  60. package/dist/invoicing/contracts/provider.js +1 -0
  61. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  62. package/dist/invoicing/contracts/settlement.js +1 -0
  63. package/dist/invoicing/contracts/store.d.ts +267 -0
  64. package/dist/invoicing/contracts/store.js +1 -0
  65. package/dist/invoicing/index.d.ts +1 -0
  66. package/dist/invoicing/index.js +1 -0
  67. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  68. package/dist/subscriptions/automaticRenewal.js +81 -0
  69. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  70. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  71. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  72. package/dist/subscriptions/contracts/commands.js +1 -0
  73. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  74. package/dist/subscriptions/contracts/dunning.js +1 -0
  75. package/dist/subscriptions/contracts/index.d.ts +6 -0
  76. package/dist/subscriptions/contracts/index.js +1 -0
  77. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  78. package/dist/subscriptions/contracts/messages.js +1 -0
  79. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  80. package/dist/subscriptions/contracts/renewal.js +1 -0
  81. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  82. package/dist/subscriptions/contracts/stockWait.js +1 -0
  83. package/dist/subscriptions/index.d.ts +39 -0
  84. package/dist/subscriptions/index.js +17 -0
  85. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  86. package/dist/subscriptions/paymentOutcomes.js +27 -0
  87. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  88. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  89. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  90. package/dist/subscriptions/postgresRetryStore.js +142 -0
  91. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  92. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  93. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  94. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  95. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  96. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  97. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  98. package/dist/subscriptions/renewalIdentity.js +4 -0
  99. package/package.json +22 -2
  100. package/release-gates.json +56 -3
  101. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  102. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  103. package/src/fulfillment/adapterConformance.ts +1 -1
  104. package/src/fulfillment/bindings.ts +1 -1
  105. package/src/fulfillment/contracts/conformance.ts +3 -1
  106. package/src/fulfillment/contracts/dispatch.ts +20 -5
  107. package/src/fulfillment/contracts/index.ts +3 -3
  108. package/src/fulfillment/contracts/observation.ts +11 -3
  109. package/src/fulfillment/contracts/readback.ts +13 -0
  110. package/src/fulfillment/contracts/store.ts +1 -0
  111. package/src/fulfillment/contracts/sync.ts +13 -2
  112. package/src/fulfillment/dispatchProjection.ts +92 -7
  113. package/src/fulfillment/index.ts +3 -2
  114. package/src/fulfillment/migrations.ts +1 -0
  115. package/src/fulfillment/observationProjection.ts +64 -13
  116. package/src/fulfillment/omnipack/client.ts +19 -7
  117. package/src/fulfillment/omnipack/index.ts +96 -39
  118. package/src/fulfillment/omnipack/mappers.ts +23 -2
  119. package/src/fulfillment/omnipack/observation.ts +45 -27
  120. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  121. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  122. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  123. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  124. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  125. package/src/fulfillment/providerDispatchWorker.ts +48 -20
  126. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  127. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  128. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  129. package/src/fulfillment/verifiedIngress.ts +44 -11
  130. package/src/invoicing/capabilities.ts +9 -0
  131. package/src/invoicing/contracts/capabilities.ts +15 -0
  132. package/src/invoicing/contracts/companyLookup.ts +13 -0
  133. package/src/invoicing/contracts/delivery.ts +24 -0
  134. package/src/invoicing/contracts/facts.ts +60 -0
  135. package/src/invoicing/contracts/host.ts +85 -0
  136. package/src/invoicing/contracts/index.ts +16 -0
  137. package/src/invoicing/contracts/provider.ts +78 -0
  138. package/src/invoicing/contracts/settlement.ts +39 -0
  139. package/src/invoicing/contracts/store.ts +98 -0
  140. package/src/invoicing/index.ts +1 -0
  141. package/src/subscriptions/automaticRenewal.ts +87 -0
  142. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  143. package/src/subscriptions/contracts/commands.ts +46 -0
  144. package/src/subscriptions/contracts/dunning.ts +48 -0
  145. package/src/subscriptions/contracts/index.ts +6 -0
  146. package/src/subscriptions/contracts/messages.ts +18 -0
  147. package/src/subscriptions/contracts/renewal.ts +325 -0
  148. package/src/subscriptions/contracts/stockWait.ts +34 -0
  149. package/src/subscriptions/index.ts +24 -0
  150. package/src/subscriptions/paymentOutcomes.ts +23 -0
  151. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  152. package/src/subscriptions/postgresRetryStore.ts +174 -0
  153. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  154. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  155. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  156. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -1,18 +1,103 @@
1
- import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchResult } from "./contracts/index.js";
1
+ import { createHash } from "node:crypto";
2
+ import type { DispatchEvidence, DispatchRecord, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentOperationError } from "./contracts/index.js";
2
3
 
4
+ /** Machine identities only: arbitrary exception prose, URLs and credentials are omitted. */
5
+ export function safeDiagnosticCode(value: unknown): string | null {
6
+ return reference(value) && typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_]{0,95}$/u.test(value) ? value : null;
7
+ }
8
+ export function localFailureReason(stage: string, error: unknown, privacy = durableStrings()): string {
9
+ if (!error || typeof error !== "object") return stage;
10
+ const fields = error as { code?: unknown; causeCode?: unknown; causeMessage?: unknown; message?: unknown };
11
+ const diagnostic = (value: unknown) => typeof value === "string" ? privacy.token(value, safeDiagnosticCode) : null;
12
+ const ownCode = diagnostic(fields.code), causeCode = diagnostic(fields.causeCode), causeMessage = diagnostic(fields.causeMessage);
13
+ const messagePattern = /^([A-Za-z][A-Za-z0-9_]*)(?::([A-Z0-9]{5}))?$/u;
14
+ const safeMessage = typeof fields.message === "string" ? privacy.token(fields.message, value => messagePattern.test(value) ? value : null) : null;
15
+ const message = safeMessage?.match(messagePattern);
16
+ const cause = causeMessage ?? (message && (message[2] || /_(?:failed|down|missing|invalid|conflict|not_found)$/u.test(message[1]!)) ? safeDiagnosticCode(message[1]) : null);
17
+ const code = causeCode ?? ownCode ?? message?.[2];
18
+ return [stage, cause, code].filter(Boolean).join(":");
19
+ }
20
+ export function operationError(error: Omit<FulfillmentOperationError, "cause"> & { cause?: unknown }): FulfillmentOperationError {
21
+ const privacy = durableStrings(error.redactedFieldCount);
22
+ const code = privacy.token(error.code, safeDiagnosticCode) ?? "operation_failed";
23
+ const cause = typeof error.cause === "string" ? privacy.token(error.cause,
24
+ value => /^ack(?::[A-Za-z0-9_]{1,96}){0,2}$/u.test(value) ? value.slice(0,180) : null) : null;
25
+ return { code, status: Number.isInteger(error.status) && error.status! >= 100 && error.status! <= 599 ? error.status : null,
26
+ retryable: error.retryable === true, mayHaveSucceeded: error.mayHaveSucceeded === true,
27
+ ...(cause ? { cause } : {}), ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}) };
28
+ }
29
+ // The fulfillment opaque-ID rule; never accept free-form metadata as a reference.
30
+ export const reference = (value: unknown): string | null => typeof value === "string"
31
+ && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value) ? value : null;
32
+ /** Opaque by default; an explicit allowlist can admit wider incumbent references. */
33
+ export function admittedReference(value: string | null | undefined, approve?: (value: string) => boolean): string | null {
34
+ if (typeof value !== "string" || /[\p{Cc}\p{Cf}]/u.test(value)) return null;
35
+ const admitted = approve ? approve(value) : reference(value) !== null;
36
+ const text = value.trim();
37
+ return admitted && text && text.length <= 256 && !/[@]|[a-z][a-z0-9+.-]*:\/\//iu.test(text) ? text : null;
38
+ }
39
+ const isoDate = (value: string | null | undefined): string | null => typeof value === "string"
40
+ && /^\d{4}-\d\d-\d\d$/u.test(value) && Number.isFinite(Date.parse(value))
41
+ && new Date(value).toISOString().slice(0, 10) === value ? value : null;
42
+ /** One counted sanitizer for durable projections; codecs are explicit host policy. */
43
+ export function durableStrings(priorCount = 0) {
44
+ let count = Number.isSafeInteger(priorCount) && priorCount > 0 ? priorCount : 0;
45
+ return {
46
+ token(value: string | null | undefined, codec: (value: string) => string | null = reference) {
47
+ if (value == null) return null;
48
+ const safe = codec(value);
49
+ if (safe === null) count += 1;
50
+ return safe;
51
+ },
52
+ get redactedFieldCount() { return count; },
53
+ };
54
+ }
3
55
  /** Copy only the durable allowlist, including when an adapter object has extra runtime properties. */
4
56
  export function dispatchEvidence(evidence: DispatchEvidence): DispatchEvidence {
57
+ const privacy = durableStrings(evidence.redactedFieldCount ?? evidence.transcript?.redactedFieldCount);
58
+ const sanitize = privacy.token;
59
+ const commandFingerprint = evidence.commandFingerprint;
60
+ const source = evidence.contactRevision.source;
61
+ const providerRef = sanitize(evidence.providerRef) ?? "redacted", carrierRef = sanitize(evidence.carrierRef), serviceRef = sanitize(evidence.serviceRef);
62
+ const mode = evidence.mapping ? (sanitize(evidence.mapping.mode) ?? "shadow") as NonNullable<DispatchEvidence["mapping"]>["mode"] : null;
63
+ const transcript = evidence.transcript ? {
64
+ itemCount: evidence.transcript.items.length,
65
+ items: evidence.transcript.items.map(item => ({ skuRef: sanitize(item.skuRef) ?? "redacted", quantity: Number.isSafeInteger(item.quantity) && item.quantity > 0 ? item.quantity : 0,
66
+ lotRef: sanitize(item.lotRef), expirationDate: sanitize(item.expirationDate, isoDate) })),
67
+ deliveryKind: evidence.transcript.deliveryKind === "parcel-locker" ? "parcel-locker" as const : "courier" as const,
68
+ pickupPointRef: sanitize(evidence.transcript.pickupPointRef),
69
+ ...(privacy.redactedFieldCount > 0 ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
70
+ } : null;
5
71
  return {
6
- commandFingerprint: evidence.commandFingerprint,
7
- contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source: evidence.contactRevision.source, revision: evidence.contactRevision.revision },
8
- providerRef: evidence.providerRef, carrierRef: evidence.carrierRef, serviceRef: evidence.serviceRef,
72
+ commandFingerprint,
73
+ contactRevision: { schemaVersion: evidence.contactRevision.schemaVersion, source, revision: evidence.contactRevision.revision },
74
+ providerRef, carrierRef, serviceRef,
75
+ ...(transcript ? { transcript } : privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}),
76
+ ...(evidence.mapping ? { mapping: { source: "dispatch_worker" as const, mode: mode!, shadowMode: evidence.mapping.mode === "shadow" } } : {}),
9
77
  };
10
78
  }
79
+ /** Shadow has a local command identity; it never invokes a provider or resolves credentials. */
80
+ export function shadowDispatchEvidence(request: FulfillmentDispatchRequest): DispatchEvidence {
81
+ if (!request.selection.providerRef || !request.lines.length || request.lines.some(line => !Number.isSafeInteger(line.quantity) || line.quantity <= 0)) throw new Error("dispatch_shadow_command_invalid");
82
+ return dispatchEvidence({ commandFingerprint: createHash("sha256").update(JSON.stringify(request)).digest("hex"),
83
+ contactRevision: request.contactRevision, providerRef: request.selection.providerRef,
84
+ carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
85
+ transcript: { itemCount: request.lines.length, items: request.lines.map(line => ({ skuRef: line.skuRef, quantity: line.quantity,
86
+ lotRef: line.lotRef ?? null, expirationDate: line.expirationDate ?? null })),
87
+ deliveryKind: request.selection.deliveryKind, pickupPointRef: request.selection.pickupPoint?.id ?? null } });
88
+ }
11
89
  export function dispatchResult(result: FulfillmentDispatchResult): FulfillmentDispatchResult {
90
+ const privacy = durableStrings(result.redactedFieldCount);
91
+ const externalRef = result.kind === "accepted" ? privacy.token(result.externalRef) ?? "redacted" : null;
92
+ const refusal = result.kind !== "accepted" ? { reason: (privacy.token(result.refusal.reason) ?? "malformed") as typeof result.refusal.reason,
93
+ phase: (privacy.token(result.refusal.phase) ?? "normalize") as typeof result.refusal.phase, retryable: result.refusal.retryable } : null;
94
+ const redactions = privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
95
+ const error = result.error ? { error: operationError({ ...result.error, mayHaveSucceeded: result.kind !== "not_dispatched" }) } : {};
12
96
  switch (result.kind) {
13
- case "accepted": return { kind: "accepted", externalRef: result.externalRef, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, evidence: dispatchEvidence(result.evidence) };
14
- case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: { reason: result.refusal.reason, phase: result.refusal.phase, retryable: result.refusal.retryable } };
15
- case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: { reason: result.refusal.reason, phase: result.refusal.phase, retryable: result.refusal.retryable } };
97
+ case "accepted": return { kind: "accepted", externalRef: externalRef!, providerAttemptKey: result.providerAttemptKey, labelKey: result.labelKey, ...redactions, evidence: dispatchEvidence(result.evidence), ...error,
98
+ ...(result.recovery ? { recovery: { source: "persisted_acceptance", proof: "persisted_external_ref", noProviderPost: true } } : {}) };
99
+ case "not_dispatched": return { kind: "not_dispatched", effectMayHaveSucceeded: false, refusal: refusal!, ...redactions, ...error };
100
+ case "unknown": return { kind: "unknown", effectMayHaveSucceeded: true, refusal: refusal!, ...redactions, ...error };
16
101
  }
17
102
  }
18
103
  export function acceptedProviderProof(record: DispatchRecord | null): record is DispatchRecord & { externalRef: string } {
@@ -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 { verifyAndNormalizeFulfillmentObservation } from "./verifiedIngress.js";
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 type { CanonicalLocalFulfillmentStatus, FulfillmentObservation } from "./contracts/index.js";
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
- export function projectCorrelation(value: FulfillmentObservation["correlation"]): FulfillmentObservation["correlation"] {
6
- const { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef } = value;
7
- return { orderRef, fulfillmentRef, externalRef, orderNumber, providerFulfillmentRef, parcelRef };
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
- return {
24
- fact: parseFulfillmentFact(value.fact), eventKind: value.eventKind, eventKey: value.eventKey,
25
- firstReceivedAt: value.firstReceivedAt,
26
- correlation: projectCorrelation(value.correlation),
27
- trackingRefs: value.trackingRefs.map(({ trackingNumber, carrierRef, serviceRef, publicUrl }) => ({ trackingNumber, carrierRef, serviceRef, publicUrl })),
28
- ...(Object.keys(diagnostics).length ? { diagnostics } : {}),
69
+ const fact = parseFulfillmentFact(value.fact);
70
+ const safe = {
71
+ ...pollEvidence(value, privacy, codecs),
72
+ fact: { ...fact, mapperVersion: token(fact.mapperVersion) ?? "redacted" }, eventKind: (token(value.eventKind) ?? "redacted") as FulfillmentObservation["eventKind"],
73
+ firstReceivedAt: timestamp.parse(value.firstReceivedAt),
74
+ trackingRefs: value.trackingRefs.flatMap(ref => {
75
+ const projected = projectTrackingReference(ref, privacy, approveTrackingUrl, codecs);
76
+ return projected ? [projected] : [];
77
+ }),
29
78
  };
79
+ if (privacy.redactedFieldCount) diagnostics.redactedFieldCount = privacy.redactedFieldCount;
80
+ return { ...safe, ...(Object.keys(diagnostics).length ? { diagnostics } : {}) };
30
81
  }
31
82
 
32
83
  const HANDOVER_STATUSES: ReadonlySet<string> = new Set(
@@ -45,7 +45,7 @@ export interface OmnipackClient {
45
45
  createProduct(payload: Record<string, unknown>, sku: string): Promise<OmnipackProductCreatedEvidence>;
46
46
  getProduct(sku: string): Promise<OmnipackProductEvidence>;
47
47
  createProductPack(sku: string, payload: Record<string, unknown>, ean: string): Promise<OmnipackProductPackCreatedEvidence>;
48
- getStock(): Promise<OmnipackStockEvidence[]>;
48
+ getStock(options?: { refuseRedirectRetry: boolean }): Promise<OmnipackStockEvidence[]>;
49
49
  getStockMovements(params?: OmnipackQueryParams): Promise<OmnipackStockMovementEvidence[]>;
50
50
  getFulfilmentsPage(params?: OmnipackQueryParams): Promise<{ rows: OmnipackFulfilmentEvidence[]; complete: boolean }>;
51
51
  getFulfilments(params?: OmnipackQueryParams): Promise<OmnipackFulfilmentEvidence[]>;
@@ -87,8 +87,8 @@ export function createOmnipackClient(
87
87
  return mapOmnipackProductPackCreatedResponse(body, sku, ean);
88
88
  },
89
89
 
90
- async getStock() {
91
- return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }));
90
+ async getStock(options) {
91
+ return mapOmnipackStockResponse(await requestJson(fetchImpl, config, "getStock", "/stock", { method: "GET" }, options?.refuseRedirectRetry));
92
92
  },
93
93
 
94
94
  async getStockMovements(params) {
@@ -98,6 +98,7 @@ export function createOmnipackClient(
98
98
  "getStockMovements",
99
99
  withQuery("/stock-movements", params),
100
100
  { method: "GET" },
101
+ true,
101
102
  ));
102
103
  },
103
104
 
@@ -141,10 +142,14 @@ async function requestJson(
141
142
  operation: string,
142
143
  path: string,
143
144
  init: RequestInit,
145
+ refuseRedirectRetry = false,
144
146
  ): Promise<unknown> {
145
147
  const method = (init.method ?? "GET").toUpperCase();
146
148
  const attempts = method === "GET" ? config.safeReadRetries + 1 : 1;
147
- let lastError: unknown = null;
149
+ // Dispatch/readback diagnostics must not change product-sync or stock retry policy.
150
+ const diagnoseRedirectFailure = refuseRedirectRetry || operation === "createOrder"
151
+ || operation === "getFulfilments" || operation === "getFulfilmentRequests";
152
+ let lastError: OmnipackProviderError | null = null;
148
153
 
149
154
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
150
155
  let submitted = false;
@@ -161,7 +166,7 @@ async function requestJson(
161
166
  lastError = new OmnipackProviderError({
162
167
  operation,
163
168
  status: response.status,
164
- code: readProviderErrorCode(body) ?? `http_${response.status}`,
169
+ code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
165
170
  retryable,
166
171
  mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
167
172
  });
@@ -173,11 +178,11 @@ async function requestJson(
173
178
  : new OmnipackProviderError({
174
179
  operation,
175
180
  status: null,
176
- code: "network_error",
181
+ code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
177
182
  retryable,
178
183
  mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
179
184
  });
180
- if (!retryable) throw lastError;
185
+ if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused")) throw lastError;
181
186
  }
182
187
  }
183
188
 
@@ -256,3 +261,10 @@ function readProviderErrorCode(body: unknown): string | null {
256
261
  function isRetryableStatus(status: number): boolean {
257
262
  return status === 408 || status === 429 || status >= 500;
258
263
  }
264
+
265
+ // Fetch implementations expose redirect refusal as an exception, sometimes through cause.
266
+ // Inspect it only to select a closed diagnostic; no exception text leaves this adapter.
267
+ function isRedirectRefusal(error: unknown): boolean {
268
+ if (!(error instanceof Error)) return false;
269
+ return /redirect/i.test(error.message) || (error.cause instanceof Error && /redirect/i.test(error.cause.message));
270
+ }
@@ -1,14 +1,46 @@
1
+ import { z } from "zod";
2
+ import { publicTrackingUrl } from "../observationProjection.js";
1
3
  import type { DispatchEvidence, FulfillmentCapabilityDescriptor, FulfillmentDispatchPort, FulfillmentDispatchRequest,
2
- FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider } from "../contracts/index.js";
4
+ FulfillmentLabelPort, FulfillmentObservationPort, FulfillmentReadbackPort, FulfillmentObservationPagePort, FulfillmentStockSyncProvider, FulfillmentProductSyncProvider, FulfillmentRefusal } from "../contracts/index.js";
3
5
  import { createOmnipackClient, OmnipackProviderError } from "./client.js";
4
6
  import { omnipackFulfillmentCapabilities } from "./capabilities.js";
5
7
  import { buildOmnipackDispatchCommand, serializeOmnipackDispatchPayload, stableDispatchHash } from "./omnipackDispatchPayload.js";
6
8
  import { mapOmnipackWebhookPayload } from "./mappers.js";
7
9
  import { verifyOmnipackWebhookToken, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
8
- import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
9
- import { digest, eventKind, observation, reference, refusal, timestamp } from "./observation.js";
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 refusal(capabilities.selected ? "not_ready" : "disabled", "sync");
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 refusal("malformed", "sync");
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 !== 5 || Object.keys(evidence.contactRevision).length !== 3
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
- return error instanceof OmnipackProviderError && !error.mayHaveSucceeded
170
- ? { kind: "not_dispatched" as const, effectMayHaveSucceeded: false as const, refusal: refusal("io_failure", "dispatch") }
171
- : { kind: "unknown" as const, effectMayHaveSucceeded: true as const, refusal: refusal("io_failure", "dispatch") };
207
+ const detail = operationError(error instanceof OmnipackProviderError ? error : { code: "provider_dispatch_failed", status: null, retryable: true, mayHaveSucceeded: true });
208
+ return !detail.mayHaveSucceeded
209
+ ? { kind: "not_dispatched" as const, effectMayHaveSucceeded: false as const, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail }
210
+ : { kind: "unknown" as const, effectMayHaveSucceeded: true as const, refusal: refusal("io_failure", "dispatch", detail.retryable), error: detail };
172
211
  } finally {
173
212
  command.body = "";
174
213
  submissions.get(fingerprint)!.completed = true;
@@ -198,13 +237,27 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
198
237
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { kind: "refused", refusal: refusal("malformed", "normalize") };
199
238
  if ("event" in payload && (typeof payload.event !== "string" || !eventKind(payload.event)))
200
239
  return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
201
- const evidence = mapOmnipackWebhookPayload(payload, config.notification.expectedEvent);
240
+ const original = payload as Record<string, unknown>;
241
+ // Validate calendar fields here only; keep compact offsets and local
242
+ // timestamps accepted by the base parser, without rewriting key bytes.
243
+ if (original.occurredAt !== undefined && original.occurredAt !== null
244
+ && (typeof original.occurredAt !== "string" || !timestamp(original.occurredAt)
245
+ || !z.iso.datetime({ offset: true, local: true }).safeParse(original.occurredAt.replace(/([+-]\d{2})(\d{2})$/u, "$1:$2")).success))
246
+ return { kind: "refused", refusal: refusal("malformed", "normalize"), diagnostics: { invalidTimestamp: true } };
247
+ const rejectedCorrelationRefs = [original.orderId, original.providerOrderId, original.orderNumber, original.fulfilmentNumber]
248
+ .filter(value => value !== undefined && value !== null && (typeof value !== "string" || !correlationReference(value, config.observationCodecs))).length;
249
+ if (rejectedCorrelationRefs)
250
+ return { kind: "refused", refusal: refusal("missing_correlation", "normalize"), diagnostics: { rejectedCorrelationRefs } };
251
+ const evidence = mapOmnipackWebhookPayload(payload, config.notification.expectedEvent, value => correlationReference(value, config.observationCodecs) !== null, {
252
+ service: value => correlationReference(value, config.observationCodecs) !== null,
253
+ url: value => publicTrackingUrl(value, config.approveTrackingUrl, config.observationCodecs?.trackingUrl) !== null,
254
+ });
202
255
  const event = eventKind(evidence.event);
203
256
  if (!event || event !== config.notification.expectedEvent) return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
204
- if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => reference(value)))
257
+ if (![evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => correlationReference(value, config.observationCodecs)))
205
258
  return { kind: "refused", refusal: refusal("missing_correlation", "normalize") };
206
259
  return { kind: "verified", observation: observation({ evidence, event, receivedAt: input.receivedAt,
207
- rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl }) };
260
+ rawDigest: digest(rawBody), evidenceRef: config.mintEvidenceReference(), source: "provider_webhook", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs }) };
208
261
  } catch { return { kind: "refused", refusal: refusal("malformed", "normalize") }; }
209
262
  },
210
263
  },
@@ -219,44 +272,37 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
219
272
  const matches = collection.rows.filter(row => input.mode === "parcel"
220
273
  ? row.fulfilmentNumber === input.externalRef : row.providerOrderId === input.externalRef);
221
274
  if (matches.length !== 1) return { kind: "unknown", refusal: refusal("local_missing", "readback") };
222
- const row = matches[0]!;
223
- const normalized = normalizeStatus(row.status);
224
- if (!normalized) return { kind: "unknown", refusal: refusal("unsupported_event", "readback") };
225
- const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
226
- const event = ({ provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" } as const)[status];
227
- const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
228
- fulfilmentNumber: row.fulfilmentNumber, occurredAt: row.updatedAt, trackingNumbers: row.trackingNumbers,
229
- trackingReferences: row.trackingReferences, shippingMethods: [] };
230
- return { kind: "found", externalRef: input.externalRef!, createdAt: timestamp(row.createdAt), updatedAt: timestamp(row.updatedAt),
231
- observation: observation({ evidence, event, status, fulfillmentRef: input.fulfillmentRef, receivedAt: config.clock(),
232
- rawDigest: digest(JSON.stringify(evidence)), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl }) };
275
+ const mapped = reconciliationObservation(matches[0]!, config, input.fulfillmentRef);
276
+ return mapped.kind === "unmapped" ? { kind: "unknown", refusal: refusal("unsupported_event", "readback") } : { ...mapped, externalRef: input.externalRef! };
233
277
  } catch { return { kind: "unknown", refusal: refusal("io_failure", "readback") }; }
234
278
  },
235
279
  },
280
+ reconciliation: createReconciliationPagePort(client, config, () => !capabilities.selected || !capabilities.readiness.ready
281
+ ? refusal(capabilities.selected ? "not_ready" : "disabled", "readback") : null),
236
282
  stockSync: {
237
283
  async getStock() {
238
284
  requireSync();
239
285
  try {
240
286
  const identity = syncIdentity();
241
- return (await client.getStock()).map(row => {
287
+ return (await client.getStock({ refuseRedirectRetry: true })).map((row, itemIndex) => {
242
288
  const skuRef = reference(row.sku);
243
- if (!skuRef) throw refusal("malformed", "sync");
289
+ if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
244
290
  return { ...identity, skuRef, quantityKind: "snapshot" as const, totalQuantity: row.totalQuantity,
245
291
  forSaleQuantity: row.forSaleQuantity, reservedOrUnavailableQuantity: row.reservedOrUnavailableQuantity };
246
292
  });
247
- } catch { throw refusal("io_failure", "sync"); }
293
+ } catch (error) { throw stockFailure(error, "get_stock"); }
248
294
  },
249
295
  async getStockMovements(input) {
250
296
  requireSync();
251
297
  try {
252
298
  const identity = syncIdentity();
253
- return (await client.getStockMovements(input)).map(row => {
299
+ return (await client.getStockMovements(input)).map((row, itemIndex) => {
254
300
  const skuRef = reference(row.sku);
255
- if (!skuRef) throw refusal("malformed", "sync");
301
+ if (!skuRef) return { kind: "rejected" as const, itemIndex, reason: "invalid_sku_ref" as const };
256
302
  return { ...identity, skuRef, quantityKind: "movement" as const, quantity: row.quantity, occurredAt: timestamp(row.occurredAt),
257
303
  operationType: reference(row.operationType), documentRef: reference(row.warehouseDocumentNumber), lotRef: reference(row.lotNumber), expirationDate: /^\d{4}-\d\d-\d\d$/u.test(row.expirationDate ?? "") ? row.expirationDate : null };
258
304
  });
259
- } catch { throw refusal("io_failure", "sync"); }
305
+ } catch (error) { throw stockFailure(error, "get_stock_movements"); }
260
306
  },
261
307
  },
262
308
  productSync: {
@@ -267,23 +313,24 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
267
313
  if (!metadata) return null;
268
314
  await client.createProduct(metadata, sku);
269
315
  return { replayed: false };
270
- } catch { throw refusal("io_failure", "sync"); }
316
+ } catch (error) { throw syncFailure("createProduct", error); }
271
317
  },
272
318
  async getProductPacks(sku) {
273
319
  requireSync();
274
- try { return (await client.getProduct(sku)).packs.filter(pack => /^\d{8,14}$/u.test(pack.ean) && Number.isSafeInteger(pack.quantity) && pack.quantity > 0).map(pack => ({ ean: pack.ean, quantity: pack.quantity })); }
275
- catch { throw refusal("io_failure", "sync"); }
320
+ requireProductSku(sku);
321
+ try { return (await client.getProduct(sku)).packs.map(pack => ({ ean: pack.ean, quantity: pack.quantity })); }
322
+ catch (error) { throw syncFailure("getProduct", error); }
276
323
  },
277
324
  async addProductPack(sku, pack) {
278
325
  requireSync();
279
- if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw refusal("malformed", "sync");
326
+ if (!/^\d{8,14}$/u.test(pack.ean) || !Number.isSafeInteger(pack.quantity) || pack.quantity <= 0) throw new OmnipackSyncError("malformed", "sync");
280
327
  try { await client.createProductPack(sku, { ean: pack.ean, quantity: pack.quantity }, pack.ean); }
281
- catch { throw refusal("io_failure", "sync"); }
328
+ catch (error) { throw syncFailure("createProductPack", error); }
282
329
  },
283
330
  async listStockedSkus() {
284
331
  requireSync();
285
- try { return [...new Set((await client.getStock()).flatMap(row => reference(row.sku) ? [row.sku] : []))]; }
286
- catch { throw refusal("io_failure", "sync"); }
332
+ try { return [...new Set((await client.getStock()).flatMap(row => row.sku.trim() ? [row.sku] : []))]; }
333
+ catch (error) { throw syncFailure("getStock", error); }
287
334
  },
288
335
  },
289
336
  };
@@ -310,3 +357,13 @@ function commandDigest(request: FulfillmentDispatchRequest): string {
310
357
  pickupPointId: request.selection.pickupPoint?.id ?? null },
311
358
  });
312
359
  }
360
+
361
+ /** Translate only the contributed client's typed error; never copy a message or payload. */
362
+ function stockFailure(error: unknown, operation: "get_stock" | "get_stock_movements") {
363
+ const failure = syncFailure(operation === "get_stock" ? "getStock" : "getStockMovements", error);
364
+ if (!(error instanceof OmnipackProviderError)) return failure;
365
+ const retryable = error.code !== "redirect_refused" && (error.retryable || error.code === "network_error");
366
+ return Object.assign(failure, { retryable, providerError: {
367
+ operation, status: error.status, code: error.code, retryable,
368
+ } });
369
+ }
@@ -58,6 +58,9 @@ export interface OmnipackFulfilmentRequestEvidence {
58
58
  }
59
59
 
60
60
  export interface OmnipackWebhookEvidence {
61
+ rejectedTrackingRefs?: number;
62
+ rejectedServices?: number;
63
+ rejectedTrackingUrls?: number;
61
64
  provider: "omnipack";
62
65
  event: string;
63
66
  providerOrderId: string | null;
@@ -177,13 +180,31 @@ export function mapOmnipackFulfilmentRequestsResponse(body: unknown): OmnipackFu
177
180
  });
178
181
  }
179
182
 
180
- export function mapOmnipackWebhookPayload(body: unknown, fallbackEvent = ""): OmnipackWebhookEvidence {
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
- const trackingReferences = trackingReferencesFrom({ shipments });
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),