@openlup/platform 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +222 -99
  2. package/README.md +59 -589
  3. package/dist/fulfillment/adapterConformance.js +1 -1
  4. package/dist/fulfillment/bindings.js +1 -1
  5. package/dist/fulfillment/contracts/conformance.d.ts +3 -1
  6. package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
  7. package/dist/fulfillment/contracts/index.d.ts +3 -3
  8. package/dist/fulfillment/contracts/observation.d.ts +21 -1
  9. package/dist/fulfillment/contracts/readback.d.ts +21 -0
  10. package/dist/fulfillment/contracts/store.d.ts +3 -0
  11. package/dist/fulfillment/contracts/sync.d.ts +58 -2
  12. package/dist/fulfillment/dispatchProjection.d.ts +20 -1
  13. package/dist/fulfillment/dispatchProjection.js +95 -6
  14. package/dist/fulfillment/index.d.ts +3 -2
  15. package/dist/fulfillment/index.js +2 -2
  16. package/dist/fulfillment/migrations.d.ts +5 -0
  17. package/dist/fulfillment/migrations.js +1 -0
  18. package/dist/fulfillment/observationProjection.d.ts +24 -3
  19. package/dist/fulfillment/observationProjection.js +65 -12
  20. package/dist/fulfillment/omnipack/client.d.ts +3 -1
  21. package/dist/fulfillment/omnipack/client.js +17 -7
  22. package/dist/fulfillment/omnipack/index.d.ts +4 -1
  23. package/dist/fulfillment/omnipack/index.js +104 -45
  24. package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
  25. package/dist/fulfillment/omnipack/mappers.js +24 -2
  26. package/dist/fulfillment/omnipack/observation.d.ts +11 -4
  27. package/dist/fulfillment/omnipack/observation.js +44 -29
  28. package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
  29. package/dist/fulfillment/omnipack/reconciliation.js +55 -0
  30. package/dist/fulfillment/postgresDispatchStore.js +1 -1
  31. package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
  32. package/dist/fulfillment/postgresShipmentSpine.js +17 -3
  33. package/dist/fulfillment/postgresStockSyncPort.js +6 -3
  34. package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
  35. package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
  36. package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
  37. package/dist/fulfillment/providerDispatchWorker.js +47 -20
  38. package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
  39. package/dist/fulfillment/providerReconciliationWorker.js +75 -19
  40. package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
  41. package/dist/fulfillment/providerStockSyncWorker.js +229 -120
  42. package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
  43. package/dist/fulfillment/verifiedIngress.d.ts +38 -11
  44. package/dist/fulfillment/verifiedIngress.js +36 -8
  45. package/dist/invoicing/capabilities.d.ts +11 -0
  46. package/dist/invoicing/capabilities.js +10 -0
  47. package/dist/invoicing/contracts/capabilities.d.ts +40 -0
  48. package/dist/invoicing/contracts/capabilities.js +1 -0
  49. package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
  50. package/dist/invoicing/contracts/companyLookup.js +1 -0
  51. package/dist/invoicing/contracts/delivery.d.ts +74 -0
  52. package/dist/invoicing/contracts/delivery.js +1 -0
  53. package/dist/invoicing/contracts/facts.d.ts +100 -0
  54. package/dist/invoicing/contracts/facts.js +1 -0
  55. package/dist/invoicing/contracts/host.d.ts +246 -0
  56. package/dist/invoicing/contracts/host.js +1 -0
  57. package/dist/invoicing/contracts/index.d.ts +16 -0
  58. package/dist/invoicing/contracts/index.js +1 -0
  59. package/dist/invoicing/contracts/provider.d.ts +126 -0
  60. package/dist/invoicing/contracts/provider.js +1 -0
  61. package/dist/invoicing/contracts/settlement.d.ts +66 -0
  62. package/dist/invoicing/contracts/settlement.js +1 -0
  63. package/dist/invoicing/contracts/store.d.ts +267 -0
  64. package/dist/invoicing/contracts/store.js +1 -0
  65. package/dist/invoicing/index.d.ts +1 -0
  66. package/dist/invoicing/index.js +1 -0
  67. package/dist/subscriptions/automaticRenewal.d.ts +44 -0
  68. package/dist/subscriptions/automaticRenewal.js +81 -0
  69. package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
  70. package/dist/subscriptions/automaticRenewalExecution.js +44 -0
  71. package/dist/subscriptions/contracts/commands.d.ts +115 -0
  72. package/dist/subscriptions/contracts/commands.js +1 -0
  73. package/dist/subscriptions/contracts/dunning.d.ts +133 -0
  74. package/dist/subscriptions/contracts/dunning.js +1 -0
  75. package/dist/subscriptions/contracts/index.d.ts +6 -0
  76. package/dist/subscriptions/contracts/index.js +1 -0
  77. package/dist/subscriptions/contracts/messages.d.ts +43 -0
  78. package/dist/subscriptions/contracts/messages.js +1 -0
  79. package/dist/subscriptions/contracts/renewal.d.ts +533 -0
  80. package/dist/subscriptions/contracts/renewal.js +1 -0
  81. package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
  82. package/dist/subscriptions/contracts/stockWait.js +1 -0
  83. package/dist/subscriptions/index.d.ts +39 -0
  84. package/dist/subscriptions/index.js +17 -0
  85. package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
  86. package/dist/subscriptions/paymentOutcomes.js +27 -0
  87. package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
  88. package/dist/subscriptions/postgresRenewalStore.js +61 -0
  89. package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
  90. package/dist/subscriptions/postgresRetryStore.js +142 -0
  91. package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
  92. package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
  93. package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
  94. package/dist/subscriptions/renewalCycleMechanism.js +157 -0
  95. package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
  96. package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
  97. package/dist/subscriptions/renewalIdentity.d.ts +3 -0
  98. package/dist/subscriptions/renewalIdentity.js +4 -0
  99. package/package.json +22 -2
  100. package/release-gates.json +56 -3
  101. package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
  102. package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
  103. package/src/fulfillment/adapterConformance.ts +1 -1
  104. package/src/fulfillment/bindings.ts +1 -1
  105. package/src/fulfillment/contracts/conformance.ts +3 -1
  106. package/src/fulfillment/contracts/dispatch.ts +20 -5
  107. package/src/fulfillment/contracts/index.ts +3 -3
  108. package/src/fulfillment/contracts/observation.ts +11 -3
  109. package/src/fulfillment/contracts/readback.ts +13 -0
  110. package/src/fulfillment/contracts/store.ts +1 -0
  111. package/src/fulfillment/contracts/sync.ts +13 -2
  112. package/src/fulfillment/dispatchProjection.ts +92 -7
  113. package/src/fulfillment/index.ts +3 -2
  114. package/src/fulfillment/migrations.ts +1 -0
  115. package/src/fulfillment/observationProjection.ts +64 -13
  116. package/src/fulfillment/omnipack/client.ts +19 -7
  117. package/src/fulfillment/omnipack/index.ts +96 -39
  118. package/src/fulfillment/omnipack/mappers.ts +23 -2
  119. package/src/fulfillment/omnipack/observation.ts +45 -27
  120. package/src/fulfillment/omnipack/reconciliation.ts +56 -0
  121. package/src/fulfillment/postgresDispatchStore.ts +1 -1
  122. package/src/fulfillment/postgresShipmentSpine.ts +21 -4
  123. package/src/fulfillment/postgresStockSyncPort.ts +6 -3
  124. package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
  125. package/src/fulfillment/providerDispatchWorker.ts +48 -20
  126. package/src/fulfillment/providerReconciliationWorker.ts +81 -25
  127. package/src/fulfillment/providerStockSyncWorker.ts +222 -126
  128. package/src/fulfillment/providerTrackingReferences.ts +3 -2
  129. package/src/fulfillment/verifiedIngress.ts +44 -11
  130. package/src/invoicing/capabilities.ts +9 -0
  131. package/src/invoicing/contracts/capabilities.ts +15 -0
  132. package/src/invoicing/contracts/companyLookup.ts +13 -0
  133. package/src/invoicing/contracts/delivery.ts +24 -0
  134. package/src/invoicing/contracts/facts.ts +60 -0
  135. package/src/invoicing/contracts/host.ts +85 -0
  136. package/src/invoicing/contracts/index.ts +16 -0
  137. package/src/invoicing/contracts/provider.ts +78 -0
  138. package/src/invoicing/contracts/settlement.ts +39 -0
  139. package/src/invoicing/contracts/store.ts +98 -0
  140. package/src/invoicing/index.ts +1 -0
  141. package/src/subscriptions/automaticRenewal.ts +87 -0
  142. package/src/subscriptions/automaticRenewalExecution.ts +39 -0
  143. package/src/subscriptions/contracts/commands.ts +46 -0
  144. package/src/subscriptions/contracts/dunning.ts +48 -0
  145. package/src/subscriptions/contracts/index.ts +6 -0
  146. package/src/subscriptions/contracts/messages.ts +18 -0
  147. package/src/subscriptions/contracts/renewal.ts +325 -0
  148. package/src/subscriptions/contracts/stockWait.ts +34 -0
  149. package/src/subscriptions/index.ts +24 -0
  150. package/src/subscriptions/paymentOutcomes.ts +23 -0
  151. package/src/subscriptions/postgresRenewalStore.ts +76 -0
  152. package/src/subscriptions/postgresRetryStore.ts +174 -0
  153. package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
  154. package/src/subscriptions/renewalCycleMechanism.ts +225 -0
  155. package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
  156. package/src/subscriptions/renewalIdentity.ts +5 -0
@@ -0,0 +1,55 @@
1
+ import { operationError } from "../dispatchProjection.js";
2
+ import { projectPollEvidence, projectObservation } from "../observationProjection.js";
3
+ import { normalizeReconciliationTimestamp } from "../providerReconciliationTimestamp.js";
4
+ import { OmnipackProviderError } from "./client.js";
5
+ import { digest, observation, reference, refusal, timestamp } from "./observation.js";
6
+ import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
7
+ export function reconciliationObservation(row, config, fulfillmentRef) {
8
+ const providerStatus = { primary: row.status, subStatus: row.subStatus };
9
+ // Original status and occurrence bytes remain part of identity, even for aliases
10
+ // mapping to the same canonical event. Received time never changes that identity.
11
+ const eventKey = digest(JSON.stringify([row.providerOrderId, row.fulfilmentNumber, row.orderNumber, row.status, row.subStatus, row.updatedAt]));
12
+ const correlation = { orderRef: null, fulfillmentRef: fulfillmentRef ?? null, externalRef: reference(row.providerOrderId),
13
+ orderNumber: reference(row.orderNumber), providerFulfillmentRef: reference(row.fulfilmentNumber), parcelRef: null };
14
+ const normalized = normalizeStatus(row.status);
15
+ if (!normalized)
16
+ return { kind: "unmapped", externalRef: correlation.externalRef, evidence: projectPollEvidence({ eventKey, correlation: { ...correlation, externalRef: row.providerOrderId, orderNumber: row.orderNumber, providerFulfillmentRef: row.fulfilmentNumber }, providerStatus }) };
17
+ const status = localStatusFor(normalized);
18
+ const event = { provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" }[status];
19
+ const occurredAt = normalizeReconciliationTimestamp(row.updatedAt);
20
+ const evidence = { provider: "omnipack", event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
21
+ fulfilmentNumber: row.fulfilmentNumber, occurredAt, trackingNumbers: row.trackingNumbers,
22
+ trackingReferences: row.trackingReferences, shippingMethods: [] };
23
+ const mapped = observation({ evidence, event, status, fulfillmentRef, receivedAt: config.clock(),
24
+ rawDigest: digest(JSON.stringify({ ...evidence, providerStatus })), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs });
25
+ return { kind: "found", externalRef: row.providerOrderId ?? "", createdAt: timestamp(row.createdAt), updatedAt: timestamp(occurredAt),
26
+ observation: projectObservation({ ...mapped, eventKey, fact: { ...mapped.fact, factId: eventKey }, providerStatus }, config.approveTrackingUrl, config.observationCodecs) };
27
+ }
28
+ export function createReconciliationPagePort(client, config, ready) {
29
+ return {
30
+ async readPage(input) {
31
+ const inactive = ready();
32
+ if (inactive)
33
+ return { kind: "failed", refusal: inactive };
34
+ if (!Number.isSafeInteger(input.page) || input.page < 0 || !Number.isSafeInteger(input.size) || input.size <= 0)
35
+ return { kind: "failed", refusal: refusal("malformed", "readback") };
36
+ try {
37
+ const rows = await client.getFulfilments(input);
38
+ return { kind: "page", rows: rows.map(row => {
39
+ try {
40
+ return reconciliationObservation(row, config);
41
+ }
42
+ catch {
43
+ return { kind: "unknown", refusal: refusal("malformed", "normalize") };
44
+ }
45
+ }) };
46
+ }
47
+ catch (error) {
48
+ if (error instanceof OmnipackProviderError && error.status === 400 && error.code === "ERR_PAGE_MUST_BE_LESS_THAN_TOTAL_PAGES")
49
+ return { kind: "page_exhausted" };
50
+ return { kind: "failed", refusal: refusal("io_failure", "readback", error instanceof OmnipackProviderError && error.retryable),
51
+ error: operationError(error instanceof OmnipackProviderError ? error : { code: "provider_read_failed", status: null, retryable: false, mayHaveSucceeded: false }) };
52
+ }
53
+ },
54
+ };
55
+ }
@@ -10,7 +10,7 @@ export function createPostgresFulfillmentDispatchStore(db, host) {
10
10
  readCandidate: ref => host.readCandidate(ref),
11
11
  prepare: ref => host.prepare(ref),
12
12
  async read(key) {
13
- const result = await db.query('SELECT public.fulfillment_dispatch_read(ref) AS response FROM public.fulfillment_dispatch_refs ref WHERE request_key=$1', [key]);
13
+ 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]);
14
14
  return result.rows[0]?.response ?? null;
15
15
  },
16
16
  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,4 @@
1
+ import type { FulfillmentObservationCodecs } from "./contracts/index.js";
1
2
  import type { PgExecutor as PgQueryExecutor } from './postgresDispatchStore.js';
2
3
  interface ShipmentSpineResult {
3
4
  shipmentId: string;
@@ -33,6 +34,8 @@ interface ShipmentSpineMutationPort {
33
34
  }
34
35
  export interface PostgresFulfillmentShipmentSpineOptions {
35
36
  portKey: string;
37
+ approveTrackingUrl?(url: string): boolean;
38
+ observationCodecs?: FulfillmentObservationCodecs;
36
39
  evidenceChannel?: "poll" | "push";
37
40
  requiredProviderType?: "simulator";
38
41
  }
@@ -41,12 +44,15 @@ export declare function createPostgresFulfillmentShipmentSpinePort(executor: PgQ
41
44
  localPersistence: ReturnType<typeof localPersistence>;
42
45
  };
43
46
  /** G17 native local persistence, composed on the same injected executor. */
44
- declare function localPersistence(executor: PgQueryExecutor): {
47
+ declare function localPersistence(executor: PgQueryExecutor, approveTrackingUrl?: (url: string) => boolean, observationCodecs?: FulfillmentObservationCodecs): {
45
48
  readLocalFulfillmentState(ref: string): Promise<{
46
49
  status: string | null;
47
50
  latestStatusOccurredAt: string | null;
48
51
  handedOverAt: string | null;
49
52
  }>;
53
+ readLocalFulfillmentStatus(ref: string): Promise<string | null>;
54
+ latestStatusOccurredAt(ref: string): Promise<string | null>;
55
+ readHandedOverAt(ref: string): Promise<string | null>;
50
56
  recordObservation(observation: import("./contracts/index.js").FulfillmentObservation): Promise<{
51
57
  replayed: boolean;
52
58
  }>;
@@ -58,6 +64,7 @@ declare function localPersistence(executor: PgQueryExecutor): {
58
64
  markProcessed(eventKey: string, outcome: {
59
65
  status: "processed" | "ignored";
60
66
  refusal?: import("./contracts/index.js").FulfillmentRefusal;
67
+ supersededCause?: string;
61
68
  }): Promise<void>;
62
69
  };
63
70
  export {};
@@ -1,3 +1,4 @@
1
+ import { safeDiagnosticCode } from "./dispatchProjection.js";
1
2
  import { nativeCall } from './postgresDispatchStore.js';
2
3
  import { projectObservation } from './observationProjection.js';
3
4
  class CommerceFulfillmentConflictError extends Error {
@@ -83,7 +84,7 @@ export function createPostgresFulfillmentShipmentSpinePort(executor, options) {
83
84
  ]);
84
85
  };
85
86
  return {
86
- localPersistence: localPersistence(executor),
87
+ localPersistence: localPersistence(executor, options.approveTrackingUrl, options.observationCodecs),
87
88
  createShipment(request) {
88
89
  return callCreate(executor, portKey, [
89
90
  request.idempotencyKey,
@@ -207,7 +208,7 @@ function asRecord(value) {
207
208
  : null;
208
209
  }
209
210
  /** G17 native local persistence, composed on the same injected executor. */
210
- function localPersistence(executor) {
211
+ function localPersistence(executor, approveTrackingUrl, observationCodecs) {
211
212
  return {
212
213
  async readLocalFulfillmentState(ref) {
213
214
  const result = await executor.query(`SELECT shipment.status,shipment.handed_over_at,
@@ -216,8 +217,20 @@ function localPersistence(executor) {
216
217
  const row = result.rows[0];
217
218
  return { status: typeof row?.status === 'string' ? row.status : null, latestStatusOccurredAt: date(row?.latest_status_occurred_at), handedOverAt: date(row?.handed_over_at) };
218
219
  },
220
+ async readLocalFulfillmentStatus(ref) {
221
+ const result = await executor.query('SELECT status FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
222
+ return typeof result.rows[0]?.status === 'string' ? result.rows[0].status : null;
223
+ },
224
+ async latestStatusOccurredAt(ref) {
225
+ 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]);
226
+ return date(result.rows[0]?.latest_status_occurred_at);
227
+ },
228
+ async readHandedOverAt(ref) {
229
+ const result = await executor.query('SELECT handed_over_at FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
230
+ return date(result.rows[0]?.handed_over_at);
231
+ },
219
232
  async recordObservation(observation) {
220
- const safe = projectObservation(observation);
233
+ const safe = projectObservation(observation, approveTrackingUrl, observationCodecs);
221
234
  return nativeCall(executor, 'fulfillment_record_observation', [safe.eventKey, safe.correlation.fulfillmentRef, JSON.stringify(safe), safe.firstReceivedAt]);
222
235
  },
223
236
  async recordStatusEvidence(ref, observation) {
@@ -225,6 +238,7 @@ function localPersistence(executor) {
225
238
  },
226
239
  async markProcessed(eventKey, outcome) {
227
240
  await nativeCall(executor, 'fulfillment_mark_observation_processed', [eventKey, JSON.stringify({ status: outcome.status,
241
+ ...(outcome.supersededCause ? { supersededCause: safeDiagnosticCode(outcome.supersededCause) ?? 'fulfillment_state_conflict' } : {}),
228
242
  ...(outcome.refusal ? { refusal: { reason: outcome.refusal.reason, phase: outcome.refusal.phase, retryable: outcome.refusal.retryable } } : {}) })]);
229
243
  },
230
244
  };
@@ -13,13 +13,13 @@ export function createPostgresFulfillmentStockSyncPort(db, options) {
13
13
  if (!row)
14
14
  return null;
15
15
  const cursor = row.cursor;
16
- return { status: row.status, lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
16
+ return { ...cursor, status: row.status, lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
17
17
  continuation: cursor.continuation ?? { nextRef: null, startLocalDate: null, endLocalDate: null }, coverageGapSkus: cursor.coverageGapSkus ?? [], refusal: cursor.refusal ?? null };
18
18
  },
19
19
  async recordCursor(v) {
20
20
  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)
21
21
  VALUES($1,$2,$3,$4,$5::jsonb,$6,now()) ON CONFLICT(source_key) DO UPDATE SET status=EXCLUDED.status,
22
- 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()`, [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]);
22
+ 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()`, [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]);
23
23
  },
24
24
  async readSkuInventoryClasses(skus) {
25
25
  if (!skus.length)
@@ -48,7 +48,10 @@ export function createPostgresFulfillmentStockSyncPort(db, options) {
48
48
  const movement = { providerRef, syncRunId, collectedAt, sourceRevision, revisionSource, skuRef, quantityKind, quantity, occurredAt, operationType, documentRef, lotRef, expirationDate };
49
49
  return nativeCall(db, 'fulfillment_record_stock_movement', [source, v.requestKey, fingerprint({ requestKey: v.requestKey, movement }), JSON.stringify(movement)]);
50
50
  },
51
- recordLowStockEvidence: v => nativeCall(db, 'fulfillment_record_stock_evidence', [source, v.requestKey, fingerprint(v), v.skuRef, v.thresholdKind, v.severity, v.providerForSaleQuantity, v.localAvailableQuantity]),
51
+ recordLowStockEvidence: v => nativeCall(db, 'fulfillment_record_stock_evidence', [source, v.requestKey,
52
+ // Quantities, severity and run evidence are observations of the same alert identity.
53
+ fingerprint({ requestKey: v.requestKey, skuRef: v.skuRef, thresholdKind: v.thresholdKind }),
54
+ v.skuRef, v.thresholdKind, v.severity, v.providerForSaleQuantity, v.localAvailableQuantity]),
52
55
  resolveLowStockEvidence: v => nativeCall(db, 'fulfillment_resolve_stock_evidence', [source, v.skuRef, v.activeThresholdKinds, v.syncRunId]),
53
56
  };
54
57
  }
@@ -20,6 +20,7 @@ export declare function recoverDispatchAcceptanceWrite(input: {
20
20
  acceptance: Extract<FulfillmentDispatchResult, {
21
21
  kind: "accepted";
22
22
  }>;
23
+ error: unknown;
23
24
  }): Promise<{
24
25
  committed: boolean;
25
26
  readBacks: number;
@@ -1,6 +1,8 @@
1
- import { dispatchEvidence } from "./dispatchProjection.js";
1
+ import { durableStrings, dispatchEvidence, dispatchResult, localFailureReason } from "./dispatchProjection.js";
2
2
  export function acknowledgeAcceptedDispatch(input) {
3
3
  return input.store.acknowledge({ dispatchRef: input.record.dispatchRef, acceptance: {
4
+ ...(input.record.acceptance ? dispatchResult(input.record.acceptance) : {}),
5
+ ...(input.record.status === "uncertain" ? { recovery: { source: "persisted_acceptance", proof: "persisted_external_ref", noProviderPost: true } } : {}),
4
6
  kind: "accepted", externalRef: input.record.externalRef, evidence: dispatchEvidence(input.record.evidence), ...input.keys,
5
7
  } });
6
8
  }
@@ -10,10 +12,14 @@ export async function recoverDispatchAcceptanceWrite(input) {
10
12
  const committed = () => record?.status === "created" && record.externalRef === input.acceptance.externalRef;
11
13
  if (committed())
12
14
  return { committed: true, readBacks };
15
+ const privacy = durableStrings();
16
+ const cause = localFailureReason("ack", input.error, privacy);
13
17
  try {
14
18
  // Accepted proof survives a failed atomic ACK; finalization records uncertainty,
15
19
  // and the next worker repairs the ACK locally without another provider call.
16
- record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: input.acceptance });
20
+ record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: { ...input.acceptance, error: {
21
+ code: "dispatch_acceptance_write_failed", cause, ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}), status: null, retryable: false, mayHaveSucceeded: true,
22
+ } } });
17
23
  readBacks += 1;
18
24
  }
19
25
  catch {
@@ -18,6 +18,19 @@ export declare function runFulfillmentDispatchWorker(input: {
18
18
  };
19
19
  now?: () => Date;
20
20
  }): Promise<{
21
+ redactedFieldCount: number;
22
+ ok: boolean;
23
+ skipped: boolean;
24
+ checked: number;
25
+ updated: number;
26
+ failures: number;
27
+ mode: "shadow" | "stage" | "live";
28
+ replayed: number;
29
+ providerCalls: number;
30
+ readBacks: number;
31
+ reason: string | undefined;
32
+ } | {
33
+ redactedFieldCount?: undefined;
21
34
  ok: boolean;
22
35
  skipped: boolean;
23
36
  checked: number;
@@ -1,21 +1,25 @@
1
- import { acceptedProviderProof, dispatchEvidence, dispatchResult } from "./dispatchProjection.js";
1
+ import { durableStrings, acceptedProviderProof, dispatchEvidence, dispatchResult, localFailureReason, shadowDispatchEvidence } from "./dispatchProjection.js";
2
2
  import { acknowledgeAcceptedDispatch, recoverDispatchAcceptanceWrite } from "./providerDispatchAcceptance.js";
3
3
  import { unknownDispatchFailure } from "./providerDispatchError.js";
4
4
  export async function runFulfillmentDispatchWorker(input) {
5
+ const privacy = durableStrings();
6
+ const failure = (stage, error) => localFailureReason(stage, error, privacy);
7
+ const diagnostics = () => privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
5
8
  const result = { ok: true, skipped: false, checked: 0, updated: 0, failures: 0,
6
9
  mode: input.config.mode, replayed: 0, providerCalls: 0, readBacks: 0, reason: undefined };
7
10
  if (input.config.mode !== "shadow") {
8
11
  try {
9
12
  await input.store.markStaleSubmissionsUncertain(new Date((input.now?.() ?? new Date()).getTime() - 5 * 60_000).toISOString());
10
13
  }
11
- catch {
12
- return { ...result, ok: false, failures: 1, reason: "dispatch_stale_submission_sweep_failed" };
14
+ catch (error) {
15
+ return { ...result, ok: false, failures: 1, reason: failure("dispatch_stale_submission_sweep_failed", error), ...diagnostics() };
13
16
  }
14
17
  }
15
18
  const candidates = await input.listCandidates(input.config.batchLimit);
16
19
  result.checked = candidates.length;
17
20
  candidateLoop: for (const candidate of candidates) {
18
21
  const requestKey = input.keys.request(candidate);
22
+ let stage = "dispatch_read_failed";
19
23
  try {
20
24
  let readBack = await input.store.read(requestKey);
21
25
  if (readBack)
@@ -27,9 +31,11 @@ export async function runFulfillmentDispatchWorker(input) {
27
31
  result.updated += ack.replayed ? 0 : 1;
28
32
  continue;
29
33
  }
34
+ stage = "dispatch_prepare_failed";
30
35
  const preparation = await input.store.prepare(candidate.fulfillmentRef);
31
36
  if (preparation.disposition === "withheld")
32
37
  continue;
38
+ stage = "dispatch_candidate_read_failed";
33
39
  const preparedCandidate = await input.store.readCandidate(candidate.fulfillmentRef);
34
40
  if (!preparedCandidate)
35
41
  throw new Error("dispatch_prepared_candidate_missing");
@@ -39,23 +45,30 @@ export async function runFulfillmentDispatchWorker(input) {
39
45
  result.reason ??= "provider_not_configured";
40
46
  continue;
41
47
  }
42
- // Shadow needs command preparation too, but never calls dispatch.
43
- if (!input.provider)
44
- throw new Error("provider_command_preparation_not_configured");
45
48
  let mappedCandidate = preparedCandidate;
46
49
  let dispatchRef;
47
50
  let evidence;
51
+ let providerEvidence;
48
52
  let mappedAttemptCount = 0;
49
53
  for (let remapCount = 0;; remapCount += 1) {
50
54
  const request = { ...mappedCandidate, requestKey };
51
- const prepared = await input.provider.prepare(request);
55
+ stage = "dispatch_command_prepare_failed";
56
+ const prepared = input.config.mode === "shadow"
57
+ ? { kind: "prepared", evidence: shadowDispatchEvidence(request) }
58
+ : await input.provider.prepare(request);
52
59
  if (prepared.kind === "refused") {
53
60
  result.ok = false;
54
61
  result.failures += 1;
55
- result.reason ??= prepared.refusal.reason;
62
+ result.reason ??= privacy.token(prepared.refusal.reason) ?? "redacted";
56
63
  continue candidateLoop;
57
64
  }
58
- evidence = dispatchEvidence(prepared.evidence);
65
+ providerEvidence = dispatchEvidence(prepared.evidence);
66
+ evidence = { ...providerEvidence, mapping: { source: "dispatch_worker", mode: input.config.mode, shadowMode: input.config.mode === "shadow" } };
67
+ // Native claims count any JSON change as a remap; attempts compare
68
+ // the whole JSON. Enrichment must not rewrite an existing identity.
69
+ if (readBack && sameCommandIdentity(readBack.evidence, evidence))
70
+ evidence = dispatchEvidence(readBack.evidence);
71
+ stage = "dispatch_claim_failed";
59
72
  const claim = await input.store.claim({ requestKey, fulfillmentRef: mappedCandidate.fulfillmentRef,
60
73
  orderRef: mappedCandidate.orderRef, parcelRef: mappedCandidate.parcelRef, mode: input.config.mode, evidence });
61
74
  if (claim.kind === "refused") {
@@ -68,18 +81,20 @@ export async function runFulfillmentDispatchWorker(input) {
68
81
  }
69
82
  result.ok = false;
70
83
  result.failures += 1;
71
- result.reason ??= claim.refusal.reason;
84
+ result.reason ??= privacy.token(claim.refusal.reason) ?? "redacted";
72
85
  continue candidateLoop;
73
86
  }
74
87
  dispatchRef = claim.record;
75
88
  if (dispatchRef.replayed)
76
89
  result.replayed += 1;
77
90
  // Re-claim may refresh a correctable draft atomically; readback is authoritative.
91
+ stage = "dispatch_readback_failed";
78
92
  readBack = await input.store.read(requestKey);
79
93
  if (readBack)
80
94
  result.readBacks += 1;
81
95
  if (!readBack || readBack.fulfillmentRef !== mappedCandidate.fulfillmentRef)
82
96
  throw new Error("dispatch_readback_failed");
97
+ stage = "dispatch_attempt_write_failed";
83
98
  await input.store.recordProviderAttempt(input.keys.mappedAttempt(requestKey, mappedAttemptCount), mappedCandidate.fulfillmentRef, evidence, input.config.mode, "recorded");
84
99
  mappedAttemptCount += 1;
85
100
  if (input.config.mode === "shadow") {
@@ -92,6 +107,7 @@ export async function runFulfillmentDispatchWorker(input) {
92
107
  result.reason ??= `dispatch_not_safe_to_submit:${readBack.status}`;
93
108
  continue candidateLoop;
94
109
  }
110
+ stage = "dispatch_begin_failed";
95
111
  const submission = await input.store.begin({ dispatchRef: dispatchRef.dispatchRef, commandFingerprint: evidence.commandFingerprint });
96
112
  if (submission.kind === "refused") {
97
113
  if (remapCount === 0 && submission.refusal.reason === "contact_stale") {
@@ -103,10 +119,11 @@ export async function runFulfillmentDispatchWorker(input) {
103
119
  }
104
120
  result.ok = false;
105
121
  result.failures += 1;
106
- result.reason ??= submission.refusal.reason;
122
+ result.reason ??= privacy.token(submission.refusal.reason) ?? "redacted";
107
123
  continue candidateLoop;
108
124
  }
109
125
  if (!submission.begun) {
126
+ stage = "dispatch_concurrent_read_failed";
110
127
  const concurrent = await input.store.read(requestKey);
111
128
  if (concurrent)
112
129
  result.readBacks += 1;
@@ -128,27 +145,32 @@ export async function runFulfillmentDispatchWorker(input) {
128
145
  result.providerCalls += 1;
129
146
  let response;
130
147
  try {
131
- response = dispatchResult(await input.provider.dispatch({ ...mappedCandidate, requestKey }, evidence));
148
+ response = dispatchResult(await input.provider.dispatch({ ...mappedCandidate, requestKey }, providerEvidence));
132
149
  }
133
150
  catch {
134
151
  response = unknownDispatchFailure();
135
152
  }
153
+ if (response.kind === "accepted" && JSON.stringify(response.evidence) !== JSON.stringify(providerEvidence)) {
154
+ response = { kind: "unknown", effectMayHaveSucceeded: true, refusal: { reason: "malformed", phase: "acknowledge", retryable: false },
155
+ error: { code: "dispatch_acceptance_evidence_mismatch", status: null, retryable: false, mayHaveSucceeded: true } };
156
+ }
136
157
  if (response.kind !== "accepted") {
158
+ stage = "dispatch_finalize_failed";
137
159
  await input.store.finalize({ dispatchRef: dispatchRef.dispatchRef, result: response });
138
160
  result.ok = false;
139
161
  result.failures += 1;
140
- result.reason ??= response.refusal.reason;
162
+ result.reason ??= response.error?.code ?? response.refusal.reason;
141
163
  continue;
142
164
  }
143
165
  // All ACK paths use the original host keys, ignoring adapter key fields.
144
- const acceptance = { ...response, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
166
+ const acceptance = { ...response, evidence, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
145
167
  try {
146
168
  const ack = await input.store.acknowledge({ dispatchRef: dispatchRef.dispatchRef, acceptance });
147
169
  if (ack.replayed)
148
170
  result.replayed += 1;
149
171
  }
150
- catch {
151
- const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance });
172
+ catch (error) {
173
+ const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance, error });
152
174
  result.readBacks += recovery.readBacks;
153
175
  if (recovery.committed) {
154
176
  result.updated += 1;
@@ -161,12 +183,17 @@ export async function runFulfillmentDispatchWorker(input) {
161
183
  }
162
184
  result.updated += 1;
163
185
  }
164
- catch {
165
- // Exception messages may contain raw provider bodies or credentials.
186
+ catch (error) {
166
187
  result.ok = false;
167
188
  result.failures += 1;
168
- result.reason ??= "dispatch_local_operation_failed";
189
+ result.reason ??= failure(stage, error);
169
190
  }
170
191
  }
171
- return result;
192
+ return { ...result, ...diagnostics() };
193
+ }
194
+ function sameCommandIdentity(a, b) {
195
+ return a.commandFingerprint === b.commandFingerprint && a.providerRef === b.providerRef
196
+ && a.carrierRef === b.carrierRef && a.serviceRef === b.serviceRef
197
+ && a.contactRevision.schemaVersion === b.contactRevision.schemaVersion
198
+ && a.contactRevision.source === b.contactRevision.source && a.contactRevision.revision === b.contactRevision.revision;
172
199
  }
@@ -1,12 +1,9 @@
1
- import type { FulfillmentObservation, FulfillmentReadbackResult } from "./contracts/index.js";
1
+ import type { FulfillmentObservation, FulfillmentObservationPagePort, FulfillmentReconciliationObservation, FulfillmentOperationError } from "./contracts/index.js";
2
2
  import { enrichTrackingReference } from "./providerTrackingReferences.js";
3
3
  type Ingress = Parameters<typeof import("./verifiedIngress.js").verifyAndNormalizeFulfillmentObservation>[0];
4
4
  type DispatchRef = NonNullable<Awaited<ReturnType<Ingress["lifecycle"]["findDispatchRef"]>>>;
5
- type PollEvidence = FulfillmentObservation | {
6
- correlation: FulfillmentObservation["correlation"];
7
- eventKey: string;
8
- };
9
- interface ReconciliationResult {
5
+ type PollEvidence = Pick<FulfillmentObservation, "correlation" | "eventKey" | "providerStatus" | "diagnostics"> | FulfillmentObservation;
6
+ export interface ReconciliationResult {
10
7
  ok: boolean;
11
8
  checked: number;
12
9
  updated: number;
@@ -17,6 +14,7 @@ interface ReconciliationResult {
17
14
  stale: number;
18
15
  offTrackSkips: number;
19
16
  replayed: number;
17
+ redactedFieldCount?: number;
20
18
  failures: number;
21
19
  invoiceIssueFailures: number;
22
20
  invoiceIssueRefused: number;
@@ -30,29 +28,21 @@ interface ReconciliationResult {
30
28
  readBacks: number;
31
29
  skipped: boolean;
32
30
  reason?: string;
31
+ providerError?: FulfillmentOperationError;
33
32
  }
34
33
  export declare function reconcileFulfillmentObservations(input: {
35
34
  /** Adapter/host maps the private page into typed readback, preserving unknowns. */
36
35
  readPage(input: {
37
36
  page: number;
38
37
  size: number;
39
- }): Promise<readonly (FulfillmentReadbackResult | {
40
- kind: "unmapped";
41
- evidence: {
42
- correlation: FulfillmentObservation["correlation"];
43
- eventKey: string;
44
- };
45
- externalRef: string | null;
46
- })[]>;
38
+ }): Promise<readonly FulfillmentReconciliationObservation[]>;
47
39
  batchSize: number;
48
40
  page?: number;
49
41
  localPersistence: {
50
42
  /** G17: local persisted chronology, never inferred from dispatch or provider state. */
51
- readLocalFulfillmentState(ref: string): Promise<{
52
- status: string | null;
53
- latestStatusOccurredAt: string | null;
54
- handedOverAt: string | null;
55
- }>;
43
+ readLocalFulfillmentStatus(ref: string): Promise<string | null>;
44
+ latestStatusOccurredAt(ref: string): Promise<string | null>;
45
+ readHandedOverAt(ref: string): Promise<string | null>;
56
46
  };
57
47
  lifecycle: Pick<Ingress["lifecycle"], "recordStatusEvidence" | "effects" | "keys"> & {
58
48
  findDispatchRef(evidence: PollEvidence): Promise<DispatchRef | null>;
@@ -79,4 +69,22 @@ export declare function reconcileFulfillmentObservations(input: {
79
69
  classifyStateConflict(error: unknown): "fulfillment_not_found" | "stock_consumed_requires_label" | null;
80
70
  };
81
71
  }): Promise<ReconciliationResult>;
72
+ /** Host owns scheduling, HTTP and durable job-ledger writes. This operation only projects them. */
73
+ export declare function runFulfillmentReconciliationPage(input: Omit<Parameters<typeof reconcileFulfillmentObservations>[0], "readPage" | "batchSize" | "page"> & {
74
+ provider: FulfillmentObservationPagePort;
75
+ pageSize: number;
76
+ checkpoint: unknown;
77
+ }): Promise<{
78
+ status: 200 | 502;
79
+ ledgerStatus: "failed" | "succeeded";
80
+ result: ReconciliationResult;
81
+ metadata: {
82
+ reconciliationPagination: {
83
+ version: number;
84
+ page: number;
85
+ pageSize: number;
86
+ nextPage: number;
87
+ };
88
+ };
89
+ }>;
82
90
  export {};