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