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