@openlup/platform 0.23.0 → 0.25.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 (165) hide show
  1. package/CHANGELOG.md +327 -269
  2. package/README.md +123 -12
  3. package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
  4. package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
  5. package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
  6. package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
  7. package/dist/composition/index.d.ts +1 -0
  8. package/dist/composition/index.js +1 -0
  9. package/dist/composition/subscriptionContribution.d.ts +18 -0
  10. package/dist/composition/subscriptionContribution.js +19 -0
  11. package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
  12. package/dist/fulfillment/dispatchProjection.d.ts +1 -1
  13. package/dist/fulfillment/dispatchProjection.js +4 -2
  14. package/dist/fulfillment/omnipack/client.js +14 -6
  15. package/dist/fulfillment/omnipack/index.js +5 -2
  16. package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
  17. package/dist/fulfillment/providerDispatchWorker.js +19 -1
  18. package/dist/inventory/index.d.ts +2 -0
  19. package/dist/inventory/index.js +1 -0
  20. package/dist/inventory/postgresStockAvailability.d.ts +24 -0
  21. package/dist/inventory/postgresStockAvailability.js +48 -0
  22. package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
  23. package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
  24. package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
  25. package/dist/invoicing/accountingLineAllocation.js +69 -0
  26. package/dist/invoicing/adapterConformance.d.ts +42 -0
  27. package/dist/invoicing/adapterConformance.js +296 -0
  28. package/dist/invoicing/artifacts.d.ts +7 -0
  29. package/dist/invoicing/artifacts.js +20 -0
  30. package/dist/invoicing/bindings.d.ts +46 -0
  31. package/dist/invoicing/bindings.js +69 -0
  32. package/dist/invoicing/channelPolicy.d.ts +28 -0
  33. package/dist/invoicing/channelPolicy.js +27 -0
  34. package/dist/invoicing/continuity.d.ts +12 -0
  35. package/dist/invoicing/continuity.js +18 -0
  36. package/dist/invoicing/contracts/facts.d.ts +11 -1
  37. package/dist/invoicing/contracts/host.d.ts +3 -4
  38. package/dist/invoicing/contracts/index.d.ts +1 -1
  39. package/dist/invoicing/contracts/store.d.ts +15 -5
  40. package/dist/invoicing/contribution.d.ts +21 -0
  41. package/dist/invoicing/contribution.js +19 -0
  42. package/dist/invoicing/correction.d.ts +42 -0
  43. package/dist/invoicing/correction.js +44 -0
  44. package/dist/invoicing/delivery.d.ts +85 -0
  45. package/dist/invoicing/delivery.js +79 -0
  46. package/dist/invoicing/documentHistory.d.ts +50 -0
  47. package/dist/invoicing/documentHistory.js +213 -0
  48. package/dist/invoicing/documentSync.d.ts +24 -0
  49. package/dist/invoicing/documentSync.js +26 -0
  50. package/dist/invoicing/dutyCollector.d.ts +15 -0
  51. package/dist/invoicing/dutyCollector.js +35 -0
  52. package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
  53. package/dist/invoicing/fulfillmentTrigger.js +90 -0
  54. package/dist/invoicing/handoff.d.ts +37 -0
  55. package/dist/invoicing/handoff.js +62 -0
  56. package/dist/invoicing/index.d.ts +38 -0
  57. package/dist/invoicing/index.js +36 -0
  58. package/dist/invoicing/issue.d.ts +39 -0
  59. package/dist/invoicing/issue.js +55 -0
  60. package/dist/invoicing/migrations.d.ts +48 -0
  61. package/dist/invoicing/migrations.js +25 -0
  62. package/dist/invoicing/nativeUnsupported.d.ts +9 -0
  63. package/dist/invoicing/nativeUnsupported.js +22 -0
  64. package/dist/invoicing/paymentReadback.d.ts +20 -0
  65. package/dist/invoicing/paymentReadback.js +47 -0
  66. package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
  67. package/dist/invoicing/postgresInvoiceStore.js +344 -0
  68. package/dist/invoicing/readModel.d.ts +74 -0
  69. package/dist/invoicing/readModel.js +90 -0
  70. package/dist/invoicing/readback.d.ts +4 -0
  71. package/dist/invoicing/readback.js +30 -0
  72. package/dist/invoicing/reversal.d.ts +37 -0
  73. package/dist/invoicing/reversal.js +28 -0
  74. package/dist/invoicing/sellerRevision.d.ts +19 -0
  75. package/dist/invoicing/sellerRevision.js +14 -0
  76. package/dist/invoicing/settlementSync.d.ts +18 -0
  77. package/dist/invoicing/settlementSync.js +32 -0
  78. package/dist/invoicing/snapshot.d.ts +52 -0
  79. package/dist/invoicing/snapshot.js +141 -0
  80. package/dist/subscriptions/commandExamples.d.ts +6 -0
  81. package/dist/subscriptions/commandExamples.js +20 -0
  82. package/dist/subscriptions/contracts/commands.d.ts +8 -1
  83. package/dist/subscriptions/contracts/index.d.ts +2 -2
  84. package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
  85. package/dist/subscriptions/index.d.ts +22 -4
  86. package/dist/subscriptions/index.js +22 -3
  87. package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
  88. package/dist/subscriptions/lifecycleMessages.js +38 -0
  89. package/dist/subscriptions/paymentOutcomes.js +1 -1
  90. package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
  91. package/dist/subscriptions/postgresCommandStore.js +172 -0
  92. package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
  93. package/dist/subscriptions/postgresRetryStore.js +11 -0
  94. package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
  95. package/dist/subscriptions/postgresStockWaitStore.js +174 -0
  96. package/dist/subscriptions/services.d.ts +31 -0
  97. package/dist/subscriptions/services.js +15 -0
  98. package/package.json +3 -3
  99. package/release-gates.json +21 -7
  100. package/sql/20260809210000_accounting_document_rail.sql +889 -0
  101. package/sql/20260811150000_accounting_payout_rail.sql +333 -0
  102. package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
  103. package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
  104. package/sql/20261022191000_invoicing_document_effects.sql +249 -0
  105. package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
  106. package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
  107. package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
  108. package/sql/20261024180000_subscription_stock_wait.sql +330 -0
  109. package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
  110. package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
  111. package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
  112. package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
  113. package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
  114. package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
  115. package/src/composition/index.ts +1 -0
  116. package/src/composition/subscriptionContribution.ts +25 -0
  117. package/src/fulfillment/contracts/dispatch.ts +4 -0
  118. package/src/fulfillment/dispatchProjection.ts +4 -2
  119. package/src/fulfillment/omnipack/client.ts +13 -6
  120. package/src/fulfillment/omnipack/index.ts +5 -2
  121. package/src/fulfillment/providerDispatchWorker.ts +14 -1
  122. package/src/inventory/index.ts +2 -0
  123. package/src/inventory/postgresStockAvailability.ts +54 -0
  124. package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
  125. package/src/invoicing/accountingLineAllocation.ts +69 -0
  126. package/src/invoicing/adapterConformance.ts +249 -0
  127. package/src/invoicing/artifacts.ts +19 -0
  128. package/src/invoicing/bindings.ts +80 -0
  129. package/src/invoicing/channelPolicy.ts +50 -0
  130. package/src/invoicing/continuity.ts +15 -0
  131. package/src/invoicing/contracts/facts.ts +12 -1
  132. package/src/invoicing/contracts/host.ts +3 -4
  133. package/src/invoicing/contracts/index.ts +1 -1
  134. package/src/invoicing/contracts/store.ts +11 -7
  135. package/src/invoicing/contribution.ts +28 -0
  136. package/src/invoicing/correction.ts +56 -0
  137. package/src/invoicing/delivery.ts +122 -0
  138. package/src/invoicing/documentHistory.ts +301 -0
  139. package/src/invoicing/documentSync.ts +51 -0
  140. package/src/invoicing/dutyCollector.ts +31 -0
  141. package/src/invoicing/fulfillmentTrigger.ts +144 -0
  142. package/src/invoicing/handoff.ts +71 -0
  143. package/src/invoicing/index.ts +38 -0
  144. package/src/invoicing/issue.ts +64 -0
  145. package/src/invoicing/migrations.ts +25 -0
  146. package/src/invoicing/nativeUnsupported.ts +24 -0
  147. package/src/invoicing/paymentReadback.ts +70 -0
  148. package/src/invoicing/postgresInvoiceStore.ts +334 -0
  149. package/src/invoicing/readModel.ts +80 -0
  150. package/src/invoicing/readback.ts +26 -0
  151. package/src/invoicing/reversal.ts +34 -0
  152. package/src/invoicing/sellerRevision.ts +20 -0
  153. package/src/invoicing/settlementSync.ts +37 -0
  154. package/src/invoicing/snapshot.ts +192 -0
  155. package/src/subscriptions/commandExamples.ts +19 -0
  156. package/src/subscriptions/contracts/commands.ts +35 -2
  157. package/src/subscriptions/contracts/index.ts +2 -2
  158. package/src/subscriptions/contracts/stockWait.ts +14 -2
  159. package/src/subscriptions/index.ts +30 -3
  160. package/src/subscriptions/lifecycleMessages.ts +37 -0
  161. package/src/subscriptions/paymentOutcomes.ts +1 -1
  162. package/src/subscriptions/postgresCommandStore.ts +183 -0
  163. package/src/subscriptions/postgresRetryStore.ts +11 -0
  164. package/src/subscriptions/postgresStockWaitStore.ts +170 -0
  165. package/src/subscriptions/services.ts +21 -0
@@ -21,8 +21,9 @@ export interface SubscriptionPaymentExpiredEmailVars {
21
21
  */
22
22
  cause?: PaymentFailureCustomerCause | null;
23
23
  /**
24
- * Recovery-link URL for the case's `resume_subscription` token. When present
25
- * the template renders the resume CTA; when absent it keeps the support-led
24
+ * Recovery-link URL for the case's payment-recovery token. Current exhaustion
25
+ * uses `repair_payment`; legacy expired cases may carry `resume_subscription`.
26
+ * When present the template renders the recovery CTA; otherwise it keeps the support-led
26
27
  * wording, because a button with no destination is worse than no button.
27
28
  */
28
29
  recoveryUrl?: string | null;
@@ -51,7 +52,7 @@ export function createSubscriptionPaymentExpiredContentFactory(input: {
51
52
  const causeSentence = subscriptionPaymentCauseSentence(locale, vars.cause ?? "unknown");
52
53
  const detail = [
53
54
  // Leads the box for the same reason as in the failed notice: it answers the
54
- // question the paused subscription raises, before the figures.
55
+ // question the unsuccessful payment raises, before the figures.
55
56
  ...(causeSentence ? [causeSentence] : []),
56
57
  ...(recoveryUrl
57
58
  ? [
@@ -19,9 +19,10 @@ export interface SubscriptionPaymentFailedEmailVars {
19
19
  recoveryUrl?: string | null;
20
20
  /**
21
21
  * Pre-formatted calendar day of the scheduled next charge, in the merchant's
22
- * zone (e.g. "11.08.2026"). The dunning row that selects THIS template always
23
- * carries a scheduled retry, so in production this is present; null keeps the
24
- * pre-date sentence rather than printing a placeholder date.
22
+ * zone (e.g. "11.08.2026"). Present only when a retry is actually scheduled:
23
+ * a first-rung hard decline selects this template with no next retry, and
24
+ * `retryScheduled: false` renders the no-retry copy. null/absent keeps the
25
+ * sentence without a date rather than printing a placeholder date.
25
26
  */
26
27
  nextRetryDateLabel?: string | null;
27
28
  /** Card scheme as stored on the method's consent snapshot, e.g. "visa". */
@@ -72,11 +73,10 @@ export function createSubscriptionPaymentFailedContentFactory(input: {
72
73
  // also why it is PREPENDED and never appended — the retry sentence must stay
73
74
  // last (see below).
74
75
  const causeSentence = subscriptionPaymentCauseSentence(locale, vars.cause ?? "unknown");
75
- // Only an explicit `false` turns the promises off. Absent means "no producer
76
- // told us", and the only rail that exists always schedules a retry, so absent
77
- // must keep rendering today's copy rather than the safer-sounding new copy:
78
- // telling a customer no attempt is coming when one is would push them to
79
- // cancel a subscription that was about to recover on its own.
76
+ // Only an explicit `false` turns the retry promises off. Absence preserves
77
+ // the legacy rendering default; it does not prove a retry is scheduled.
78
+ // Producers must pass the actual schedule fact when retries have stopped,
79
+ // including class termination or exhaustion.
80
80
  const retryScheduled = vars.retryScheduled !== false;
81
81
  const detail = [
82
82
  ...(causeSentence ? [causeSentence] : []),
@@ -20,3 +20,4 @@ export { createCatalogContribution, type CatalogContributionInput } from "./cata
20
20
  export { createInventoryContribution } from "./inventoryContribution.js";
21
21
  export { createShippingContribution } from "./shippingContribution.js";
22
22
  export { createFulfillmentContribution } from "./fulfillmentContribution.js";
23
+ export { createSubscriptionContribution } from "./subscriptionContribution.js";
@@ -0,0 +1,25 @@
1
+ import type { Contribution, RequiredSchema } from "@openlup/core/readiness";
2
+
3
+ /** One selected subscription job; no second scheduler or automatic registration. */
4
+ export function createSubscriptionContribution(input: {
5
+ version: string;
6
+ requiredSchema: RequiredSchema;
7
+ run: (() => Promise<unknown>) | null;
8
+ cadenceSeconds: number;
9
+ }) {
10
+ if (!Number.isFinite(input.cadenceSeconds) || input.cadenceSeconds <= 0) throw new Error("subscription_cadence_invalid");
11
+ const schedules = input.run ? [{ id: "subscriptions-renewal", cadenceSeconds: input.cadenceSeconds, run: input.run }] : [];
12
+ const contribution: Contribution = {
13
+ manifest: {
14
+ name: "@openlup/platform", version: input.version, kind: "rail",
15
+ module: { id: "subscriptions", kind: "capability", provides: ["./subscriptions", "./subscriptions/contracts"], dependsOn: [] },
16
+ emits: [], handles: [], schedules: schedules.map(({ id, cadenceSeconds }) => ({ id, cadenceSeconds })),
17
+ env: [], requiredPorts: ["run"], routes: [], requiredSchema: input.requiredSchema,
18
+ }, handlers: [], schedules, routes: [],
19
+ };
20
+ return {
21
+ contributions: [contribution],
22
+ ports: input.run ? { "@openlup/platform/subscriptions:run": input.run } : {},
23
+ triggers: Object.fromEntries(schedules.map(schedule => [`@openlup/platform/subscriptions:${schedule.id}`, schedule.run])),
24
+ };
25
+ }
@@ -44,6 +44,10 @@ export interface DispatchEvidence {
44
44
  export interface FulfillmentOperationError {
45
45
  redactedFieldCount?: number;
46
46
  code: string; status: number | null; retryable: boolean; mayHaveSucceeded: boolean;
47
+ /** Whether provider submission was attempted; absent means unknown, never proven false. */
48
+ submissionAttempted?: boolean;
49
+ /** Whether the HTTP response body was read, including empty or malformed JSON; absent means no body-read evidence. */
50
+ responseBodyRead?: boolean;
47
51
  /** Machine cause only; never an arbitrary exception message. */
48
52
  cause?: string;
49
53
  }
@@ -24,6 +24,8 @@ export function operationError(error: Omit<FulfillmentOperationError, "cause"> &
24
24
  value => /^ack(?::[A-Za-z0-9_]{1,96}){0,2}$/u.test(value) ? value.slice(0,180) : null) : null;
25
25
  return { code, status: Number.isInteger(error.status) && error.status! >= 100 && error.status! <= 599 ? error.status : null,
26
26
  retryable: error.retryable === true, mayHaveSucceeded: error.mayHaveSucceeded === true,
27
+ ...(typeof error.submissionAttempted === "boolean" ? { submissionAttempted: error.submissionAttempted } : {}),
28
+ ...(typeof error.responseBodyRead === "boolean" ? { responseBodyRead: error.responseBodyRead } : {}),
27
29
  ...(cause ? { cause } : {}), ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}) };
28
30
  }
29
31
  // The fulfillment opaque-ID rule; never accept free-form metadata as a reference.
@@ -77,9 +79,9 @@ export function dispatchEvidence(evidence: DispatchEvidence): DispatchEvidence {
77
79
  };
78
80
  }
79
81
  /** Shadow has a local command identity; it never invokes a provider or resolves credentials. */
80
- export function shadowDispatchEvidence(request: FulfillmentDispatchRequest): DispatchEvidence {
82
+ export function shadowDispatchEvidence(request: FulfillmentDispatchRequest, commandFingerprint?: string): DispatchEvidence {
81
83
  if (!request.selection.providerRef || !request.lines.length || request.lines.some(line => !Number.isSafeInteger(line.quantity) || line.quantity <= 0)) throw new Error("dispatch_shadow_command_invalid");
82
- return dispatchEvidence({ commandFingerprint: createHash("sha256").update(JSON.stringify(request)).digest("hex"),
84
+ return dispatchEvidence({ commandFingerprint: commandFingerprint ?? createHash("sha256").update(JSON.stringify(request)).digest("hex"),
83
85
  contactRevision: request.contactRevision, providerRef: request.selection.providerRef,
84
86
  carrierRef: request.selection.carrierRef, serviceRef: request.selection.serviceRef,
85
87
  transcript: { itemCount: request.lines.length, items: request.lines.map(line => ({ skuRef: line.skuRef, quantity: line.quantity,
@@ -153,35 +153,42 @@ async function requestJson(
153
153
 
154
154
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
155
155
  let submitted = false;
156
+ let responseBodyRead: boolean | undefined;
157
+ const dispatchFacts = () => operation === "createOrder" ? { submissionAttempted: submitted,
158
+ ...(responseBodyRead !== undefined ? { responseBodyRead } : {}) } : {};
156
159
  try {
157
160
  const base = new URL(config.baseUrl);
158
161
  const url = new URL(path, base);
159
162
  if (url.origin !== base.origin || url.username || url.password || url.protocol !== "https:") throw new Error("Invalid fulfillment path");
160
163
  const response = await fetchWithTimeout(fetchImpl, url, config, init, () => { submitted = true; });
161
- if (response.ok) return await readJsonBody(response);
164
+ responseBodyRead = false;
165
+ const body = await readJsonBody(response).then(value => { responseBodyRead = true; return value; }).catch(error => {
166
+ if (response.ok) throw error;
167
+ return null;
168
+ });
169
+ if (response.ok) return body;
162
170
 
163
171
  const retryable = method === "GET" && isRetryableStatus(response.status);
164
172
  // A failed body read cannot erase the rejection status already received.
165
- const body = await readJsonBody(response).catch(() => null);
166
- lastError = new OmnipackProviderError({
173
+ lastError = Object.assign(new OmnipackProviderError({
167
174
  operation,
168
175
  status: response.status,
169
176
  code: refuseRedirectRetry && response.status >= 300 && response.status < 400 ? "redirect_refused" : readProviderErrorCode(body) ?? `http_${response.status}`,
170
177
  retryable,
171
178
  mayHaveSucceeded: mayPostHaveSucceeded(method, response.status),
172
- });
179
+ }), dispatchFacts());
173
180
  if (!retryable || attempt === attempts) throw lastError;
174
181
  } catch (error) {
175
182
  const retryable = method === "GET" && attempt < attempts;
176
183
  lastError = submitted && error instanceof OmnipackProviderError
177
184
  ? error
178
- : new OmnipackProviderError({
185
+ : Object.assign(new OmnipackProviderError({
179
186
  operation,
180
187
  status: null,
181
188
  code: diagnoseRedirectFailure && isRedirectRefusal(error) ? "redirect_refused" : "network_error",
182
189
  retryable,
183
190
  mayHaveSucceeded: submitted && mayPostHaveSucceeded(method, null),
184
- });
191
+ }), dispatchFacts());
185
192
  if (!retryable || (refuseRedirectRetry && lastError.code === "redirect_refused")) throw lastError;
186
193
  }
187
194
  }
@@ -239,10 +239,13 @@ export function createOmnipackFulfillmentAdapter(config: OmnipackFulfillmentAdap
239
239
  return { kind: "refused", refusal: refusal("unsupported_event", "normalize") };
240
240
  const original = payload as Record<string, unknown>;
241
241
  // Validate calendar fields here only; keep compact offsets and local
242
- // timestamps accepted by the base parser, without rewriting key bytes.
242
+ // timestamps and minute precision accepted by the base parser,
243
+ // without rewriting key bytes.
243
244
  if (original.occurredAt !== undefined && original.occurredAt !== null
244
245
  && (typeof original.occurredAt !== "string" || !timestamp(original.occurredAt)
245
- || !z.iso.datetime({ offset: true, local: true }).safeParse(original.occurredAt.replace(/([+-]\d{2})(\d{2})$/u, "$1:$2")).success))
246
+ || !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))
246
249
  return { kind: "refused", refusal: refusal("malformed", "normalize"), diagnostics: { invalidTimestamp: true } };
247
250
  const rejectedCorrelationRefs = [original.orderId, original.providerOrderId, original.orderNumber, original.fulfilmentNumber]
248
251
  .filter(value => value !== undefined && value !== null && (typeof value !== "string" || !correlationReference(value, config.observationCodecs))).length;
@@ -14,6 +14,8 @@ export async function runFulfillmentDispatchWorker(input: {
14
14
  acceptance(fulfillmentRef: string): { providerAttemptKey: string; labelKey: string };
15
15
  };
16
16
  config: { mode: DispatchRecord["mode"]; batchLimit: number };
17
+ /** Pure local shadow fingerprint: a lowercase 64-hex SHA-256 digest of the adopter's canonical command bytes. Invalid output or a throw refuses before evidence writes. Never return raw command/PII. Used across shadow claim/readback/attempt; shadow never begins, and live preparation must produce the same identity. */
18
+ shadowCommandFingerprint?: (request: FulfillmentDispatchRequest) => string;
17
19
  now?: () => Date;
18
20
  }) {
19
21
  const privacy = durableStrings();
@@ -56,8 +58,19 @@ export async function runFulfillmentDispatchWorker(input: {
56
58
  for (let remapCount = 0; ; remapCount += 1) {
57
59
  const request = { ...mappedCandidate, requestKey };
58
60
  stage = "dispatch_command_prepare_failed";
61
+ let shadowFingerprint: string | undefined;
62
+ if (input.config.mode === "shadow" && input.shadowCommandFingerprint) {
63
+ let value: unknown;
64
+ let cause: "adopter_fingerprint_invalid" | "adopter_fingerprint_failed" | undefined;
65
+ try { value = input.shadowCommandFingerprint(request); }
66
+ catch { cause = "adopter_fingerprint_failed"; }
67
+ if (!cause && typeof value === "string" && /^[a-f0-9]{64}$/.test(value)) shadowFingerprint = value;
68
+ else cause ??= "adopter_fingerprint_invalid";
69
+ if (cause) return { ...result, ok: false, failures: result.failures + 1, reason: cause,
70
+ refusal: { reason: "malformed" as const, phase: "prepare" as const, retryable: false as const, cause }, ...diagnostics() };
71
+ }
59
72
  const prepared = input.config.mode === "shadow"
60
- ? { kind: "prepared" as const, evidence: shadowDispatchEvidence(request) }
73
+ ? { kind: "prepared" as const, evidence: shadowDispatchEvidence(request, shadowFingerprint) }
61
74
  : await input.provider!.prepare(request);
62
75
  if (prepared.kind === "refused") {
63
76
  result.ok = false; result.failures += 1; result.reason ??= privacy.token(prepared.refusal.reason) ?? "redacted"; continue candidateLoop;
@@ -20,3 +20,5 @@ export { forecastSubscriptionInventoryShortages, buildSubscriptionInventoryForec
20
20
  export { calculateSubscriptionForecastSkus, calculateSubscriptionForecastTotals } from "./subscriptionForecastCalculation.js";
21
21
  export { INVENTORY_MIGRATIONS } from "./sql.js";
22
22
  export { createInventoryBindings, type InventoryBindingDependencies, type InventoryBindings } from "./bindings.js";
23
+ export { createPostgresStockAvailability } from "./postgresStockAvailability.js";
24
+ export type { NativeStockPromise } from "./postgresStockAvailability.js";
@@ -0,0 +1,54 @@
1
+ import type { NewPurchaseStockRow } from "./contracts/newPurchaseStock.js";
2
+ import type { NewPurchaseStockReadPort } from "./newPurchaseStockAvailability.js";
3
+
4
+ interface StockQueryExecutor {
5
+ query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
6
+ }
7
+ export interface NativeStockPromise {
8
+ availableQuantity: number; forSaleQuantity: number; hardHoldQuantity: number;
9
+ safetyQuantity: number; softSubscriptionDemandQuantity: number; channelExposureQuantity: number;
10
+ }
11
+ function object(value: unknown): Record<string, unknown> {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("inventory_stock_response_invalid");
13
+ return value as Record<string, unknown>;
14
+ }
15
+ function quantity(value: unknown): number {
16
+ if ((typeof value !== "number" && typeof value !== "string") || String(value).trim() === "") throw new Error("inventory_stock_quantity_invalid");
17
+ const n = Number(value);
18
+ if (!Number.isSafeInteger(n) || n < 0) throw new Error("inventory_stock_quantity_invalid");
19
+ return n;
20
+ }
21
+ /** Native read-only ATP. The fixed 14-day priority cannot be waived by a new
22
+ * purchase caller. Only the fenced native SQL allocator uses the renewal exemption. */
23
+ export function createPostgresStockAvailability(executor: StockQueryExecutor, options: { sourceKey: string; now: () => string }): {
24
+ readPort: NewPurchaseStockReadPort;
25
+ availableToPromise(sku: string, asOf: string): Promise<NativeStockPromise>;
26
+ } {
27
+ return {
28
+ async availableToPromise(sku, asOf) {
29
+ const row = object((await executor.query(`SELECT public.inventory_available_to_promise(
30
+ $1::text,$2::text,$3::timestamptz,false) AS response`, [options.sourceKey, sku, asOf])).rows[0]?.response);
31
+ return { availableQuantity: quantity(row.available_quantity), forSaleQuantity: quantity(row.for_sale_quantity),
32
+ hardHoldQuantity: quantity(row.hard_hold_quantity), safetyQuantity: quantity(row.safety_quantity),
33
+ softSubscriptionDemandQuantity: quantity(row.soft_subscription_demand_quantity), channelExposureQuantity: quantity(row.channel_exposure_quantity) };
34
+ },
35
+ readPort: {
36
+ async read(input) {
37
+ if (input.horizonDays !== 14 || input.excludeOrderId !== null) throw new Error("native_stock_read_options_unsupported");
38
+ if (!input.skus.length) return new Map();
39
+ const value = (await executor.query(`SELECT public.inventory_new_purchase_stock_read(
40
+ $1::text,$2::jsonb,$3::timestamptz) AS response`, [options.sourceKey, JSON.stringify(input.skus), options.now()])).rows[0]?.response;
41
+ if (!Array.isArray(value)) throw new Error("inventory_stock_response_invalid");
42
+ return new Map(value.map(value => {
43
+ const row = object(value);
44
+ if (typeof row.sku !== "string" || !["provider", "missing"].includes(String(row.source)) || typeof row.stale !== "boolean") throw new Error("inventory_stock_response_invalid");
45
+ const result: NewPurchaseStockRow = { sku: row.sku, catalogSkuId: row.catalogSkuId == null ? null : String(row.catalogSkuId),
46
+ source: row.source as "provider" | "missing", providerForSale: row.providerForSale == null ? null : quantity(row.providerForSale),
47
+ activeHolds: quantity(row.activeHolds), safetyStock: quantity(row.safetyStock), available: quantity(row.available), stale: row.stale,
48
+ renewalDemand: quantity(row.renewalDemand), availableForNewPurchases: quantity(row.availableForNewPurchases), excludedOrderHolds: quantity(row.excludedOrderHolds) };
49
+ return [result.sku, result];
50
+ }));
51
+ },
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,134 @@
1
+ import type { InvoiceLine } from "./contracts/facts.js";
2
+ import { allocateDiscountedGrossTotals } from "./accountingLineAllocation.js";
3
+
4
+ export function mapCanonicalIssueLines(
5
+ linesSnapshot: unknown[],
6
+ chargedTotalGrossMinor: number,
7
+ chargedTotalNetMinor: number,
8
+ defaultLabel: string,
9
+ ): Array<Pick<InvoiceLine, "quantity" | "unitGrossMinor" | "unitNetMinor" | "totalGrossMinor" | "totalNetMinor"> & { name: string; vatRate: string }> {
10
+ const lines = linesSnapshot.map((value) => {
11
+ const row = isRecord(value) ? value : {};
12
+ const quantity = requiredPositiveInteger(row.quantity, "quantity");
13
+ const totalGrossMinor = requiredNonnegativeInteger(row.totalGrossMinor, "total_gross");
14
+ const totalNetMinor = requiredNonnegativeInteger(row.totalNetMinor, "total_net");
15
+ const vatRateBps = requiredNonnegativeInteger(row.vatRateBps, "vat_rate_bps");
16
+ if (vatRateBps > 10_000 || netFromGross(totalGrossMinor, vatRateBps) !== totalNetMinor) {
17
+ throw new Error("accounting_invoice_canonical_position_vat_mismatch");
18
+ }
19
+ const vatRate = canonicalVatRate(vatRateBps);
20
+ const suppliedVatRate = textValue(row.vatRate);
21
+ if (suppliedVatRate !== null && normalizeVatRate(suppliedVatRate) !== vatRate) {
22
+ throw new Error("accounting_invoice_canonical_position_vat_label_mismatch");
23
+ }
24
+ return {
25
+ positionKind: textValue(row.positionKind),
26
+ name: textValue(row.name) ?? defaultLabel,
27
+ quantity,
28
+ unitGrossMinor: requiredNonnegativeInteger(row.unitGrossMinor, "unit_gross"),
29
+ unitNetMinor: requiredNonnegativeInteger(row.unitNetMinor, "unit_net"),
30
+ totalGrossMinor,
31
+ totalNetMinor,
32
+ vatRate,
33
+ catalogTotalGrossMinor: numberValue(row.catalogTotalGrossMinor),
34
+ discountAllocatedMinor: numberValue(row.discountAllocatedMinor),
35
+ shippingGrossMinor: numberValue(row.shippingGrossMinor),
36
+ shippingDiscountMinor: numberValue(row.shippingDiscountMinor),
37
+ };
38
+ });
39
+ const items = lines.filter((line) => line.positionKind === "item");
40
+ const delivery = lines.filter((line) => line.positionKind === "delivery");
41
+ if (items.length === 0 || delivery.length > 1) {
42
+ throw new Error("accounting_invoice_canonical_position_cardinality_invalid");
43
+ }
44
+
45
+ const actualDelivery = delivery[0]?.totalGrossMinor ?? 0;
46
+ if (delivery.length === 1) {
47
+ const shippingGross = delivery[0]!.shippingGrossMinor;
48
+ const shippingDiscount = delivery[0]!.shippingDiscountMinor;
49
+ if (!Number.isSafeInteger(shippingGross) || !Number.isSafeInteger(shippingDiscount)
50
+ || shippingGross! < 0 || shippingDiscount! < 0 || shippingDiscount! > shippingGross!
51
+ || shippingGross! - shippingDiscount! !== actualDelivery) {
52
+ throw new Error("accounting_invoice_canonical_delivery_mismatch");
53
+ }
54
+ }
55
+
56
+ const catalogTotals = items.map((line) => {
57
+ if (!Number.isInteger(line.catalogTotalGrossMinor) || (line.catalogTotalGrossMinor ?? -1) < 0) {
58
+ throw new Error("accounting_invoice_canonical_catalog_total_invalid");
59
+ }
60
+ if (!Number.isInteger(line.discountAllocatedMinor) || (line.discountAllocatedMinor ?? -1) < 0) {
61
+ throw new Error("accounting_invoice_canonical_discount_allocation_invalid");
62
+ }
63
+ if (line.totalGrossMinor + line.discountAllocatedMinor! !== line.catalogTotalGrossMinor) {
64
+ throw new Error("accounting_invoice_canonical_line_allocation_mismatch");
65
+ }
66
+ return line.catalogTotalGrossMinor!;
67
+ });
68
+ const independentlyAllocated = allocateDiscountedGrossTotals(
69
+ catalogTotals,
70
+ chargedTotalGrossMinor - actualDelivery,
71
+ );
72
+ if (items.some((line, index) => line.totalGrossMinor !== independentlyAllocated[index])) {
73
+ throw new Error("accounting_invoice_canonical_allocator_delta_nonzero");
74
+ }
75
+
76
+ const grossSum = lines.reduce((sum, line) => sum + line.totalGrossMinor, 0);
77
+ const netSum = lines.reduce((sum, line) => sum + line.totalNetMinor, 0);
78
+ if (grossSum !== chargedTotalGrossMinor || netSum !== chargedTotalNetMinor) {
79
+ throw new Error(
80
+ `accounting_invoice_canonical_positions_header_mismatch gross=${grossSum}/${chargedTotalGrossMinor} net=${netSum}/${chargedTotalNetMinor}`,
81
+ );
82
+ }
83
+
84
+ return lines.map(({
85
+ positionKind: _kind,
86
+ catalogTotalGrossMinor: _catalog,
87
+ discountAllocatedMinor: _discount,
88
+ shippingGrossMinor: _shippingGross,
89
+ shippingDiscountMinor: _shippingDiscount,
90
+ ...line
91
+ }) => line);
92
+ }
93
+
94
+ function requiredNonnegativeInteger(value: unknown, field: string): number {
95
+ const number = numberValue(value);
96
+ if (number === null || !Number.isSafeInteger(number) || number < 0) {
97
+ throw new Error(`accounting_invoice_canonical_${field}_invalid`);
98
+ }
99
+ return number;
100
+ }
101
+
102
+ function requiredPositiveInteger(value: unknown, field: string): number {
103
+ const number = requiredNonnegativeInteger(value, field);
104
+ if (number === 0) throw new Error(`accounting_invoice_canonical_${field}_invalid`);
105
+ return number;
106
+ }
107
+
108
+ function netFromGross(gross: number, vatRateBps: number): number {
109
+ return Math.round(gross * 10_000 / (10_000 + vatRateBps));
110
+ }
111
+
112
+ function canonicalVatRate(vatRateBps: number): string {
113
+ return normalizeVatRate(String(vatRateBps / 100));
114
+ }
115
+
116
+ function normalizeVatRate(value: string): string {
117
+ const parsed = Number(value.replace(",", "."));
118
+ if (!Number.isFinite(parsed) || parsed < 0) {
119
+ throw new Error("accounting_invoice_canonical_position_vat_label_invalid");
120
+ }
121
+ return String(parsed);
122
+ }
123
+
124
+ function textValue(value: unknown): string | null {
125
+ return typeof value === "string" && value.trim() ? value.trim() : null;
126
+ }
127
+
128
+ function numberValue(value: unknown): number | null {
129
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
134
+ }
@@ -0,0 +1,69 @@
1
+ // Independent document-boundary allocation. The database writer performs its
2
+ // own SQL allocation; this copy deliberately verifies persisted results rather
3
+ // than sharing an implementation with the order writer.
4
+ export function allocateChargedGrossTotals(lineTotals: number[], chargedTotal: number): number[] {
5
+ if (!Number.isInteger(chargedTotal) || chargedTotal < 0) {
6
+ throw new Error(`accounting_invoice_charged_total_invalid charged=${chargedTotal}`);
7
+ }
8
+ if (lineTotals.some((total) => !Number.isInteger(total) || total < 0)) {
9
+ throw new Error("accounting_invoice_line_totals_invalid");
10
+ }
11
+ const linesTotal = lineTotals.reduce((sum, total) => sum + total, 0);
12
+ if (linesTotal === chargedTotal) return lineTotals;
13
+ if (linesTotal < chargedTotal) {
14
+ throw new Error(
15
+ `accounting_invoice_lines_below_charged_total lines=${linesTotal} charged=${chargedTotal}`,
16
+ );
17
+ }
18
+ const scaled = lineTotals.map((total) => (total * chargedTotal) / linesTotal);
19
+ const allocated = scaled.map((value) => Math.floor(value));
20
+ let remainder = chargedTotal - allocated.reduce((sum, total) => sum + total, 0);
21
+ const byFraction = scaled
22
+ .map((value, index) => ({ index, fraction: value - allocated[index] }))
23
+ .sort((a, b) => b.fraction - a.fraction || a.index - b.index);
24
+ for (const { index } of byFraction) {
25
+ if (remainder <= 0) break;
26
+ allocated[index] += 1;
27
+ remainder -= 1;
28
+ }
29
+ return allocated;
30
+ }
31
+
32
+ // Mirrors the order writer's discount allocation rule for verification only:
33
+ // allocate the discount (not the charged total) by largest remainder, then
34
+ // subtract it from each catalog line. Allocating the charged total directly
35
+ // reverses the deterministic tie-break for equal lines.
36
+ export function allocateDiscountedGrossTotals(lineTotals: number[], chargedTotal: number): number[] {
37
+ const linesTotal = lineTotals.reduce((sum, total) => sum + total, 0);
38
+ const discount = linesTotal - chargedTotal;
39
+ if (!Number.isInteger(chargedTotal) || chargedTotal < 0 || discount < 0) {
40
+ throw new Error(`accounting_invoice_charged_total_invalid charged=${chargedTotal}`);
41
+ }
42
+ if (lineTotals.some((total) => !Number.isInteger(total) || total < 0)) {
43
+ throw new Error("accounting_invoice_line_totals_invalid");
44
+ }
45
+ if (discount === 0) return lineTotals;
46
+ if (linesTotal === 0) {
47
+ throw new Error("accounting_invoice_discount_allocation_zero_spread");
48
+ }
49
+
50
+ const sumExact = BigInt(linesTotal);
51
+ const discountExact = BigInt(discount);
52
+ const scaledDiscounts = lineTotals.map((total) => {
53
+ const product = BigInt(total) * discountExact;
54
+ return { base: product / sumExact, remainder: product % sumExact };
55
+ });
56
+ const allocatedDiscounts = scaledDiscounts.map(({ base }) => Number(base));
57
+ let remainder = discount - allocatedDiscounts.reduce((sum, total) => sum + total, 0);
58
+ const byFraction = scaledDiscounts
59
+ .map((value, index) => ({ index, fraction: value.remainder }))
60
+ .sort((a, b) => a.fraction === b.fraction
61
+ ? a.index - b.index
62
+ : a.fraction > b.fraction ? -1 : 1);
63
+ for (const { index } of byFraction) {
64
+ if (remainder <= 0) break;
65
+ allocatedDiscounts[index] += 1;
66
+ remainder -= 1;
67
+ }
68
+ return lineTotals.map((total, index) => total - allocatedDiscounts[index]);
69
+ }