@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.
- package/CHANGELOG.md +327 -269
- package/README.md +123 -12
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
- package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
- package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/subscriptionContribution.d.ts +18 -0
- package/dist/composition/subscriptionContribution.js +19 -0
- package/dist/fulfillment/contracts/dispatch.d.ts +4 -0
- package/dist/fulfillment/dispatchProjection.d.ts +1 -1
- package/dist/fulfillment/dispatchProjection.js +4 -2
- package/dist/fulfillment/omnipack/client.js +14 -6
- package/dist/fulfillment/omnipack/index.js +5 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +38 -0
- package/dist/fulfillment/providerDispatchWorker.js +19 -1
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +1 -0
- package/dist/inventory/postgresStockAvailability.d.ts +24 -0
- package/dist/inventory/postgresStockAvailability.js +48 -0
- package/dist/invoicing/accountingCanonicalProviderLines.d.ts +5 -0
- package/dist/invoicing/accountingCanonicalProviderLines.js +104 -0
- package/dist/invoicing/accountingLineAllocation.d.ts +2 -0
- package/dist/invoicing/accountingLineAllocation.js +69 -0
- package/dist/invoicing/adapterConformance.d.ts +42 -0
- package/dist/invoicing/adapterConformance.js +296 -0
- package/dist/invoicing/artifacts.d.ts +7 -0
- package/dist/invoicing/artifacts.js +20 -0
- package/dist/invoicing/bindings.d.ts +46 -0
- package/dist/invoicing/bindings.js +69 -0
- package/dist/invoicing/channelPolicy.d.ts +28 -0
- package/dist/invoicing/channelPolicy.js +27 -0
- package/dist/invoicing/continuity.d.ts +12 -0
- package/dist/invoicing/continuity.js +18 -0
- package/dist/invoicing/contracts/facts.d.ts +11 -1
- package/dist/invoicing/contracts/host.d.ts +3 -4
- package/dist/invoicing/contracts/index.d.ts +1 -1
- package/dist/invoicing/contracts/store.d.ts +15 -5
- package/dist/invoicing/contribution.d.ts +21 -0
- package/dist/invoicing/contribution.js +19 -0
- package/dist/invoicing/correction.d.ts +42 -0
- package/dist/invoicing/correction.js +44 -0
- package/dist/invoicing/delivery.d.ts +85 -0
- package/dist/invoicing/delivery.js +79 -0
- package/dist/invoicing/documentHistory.d.ts +50 -0
- package/dist/invoicing/documentHistory.js +213 -0
- package/dist/invoicing/documentSync.d.ts +24 -0
- package/dist/invoicing/documentSync.js +26 -0
- package/dist/invoicing/dutyCollector.d.ts +15 -0
- package/dist/invoicing/dutyCollector.js +35 -0
- package/dist/invoicing/fulfillmentTrigger.d.ts +56 -0
- package/dist/invoicing/fulfillmentTrigger.js +90 -0
- package/dist/invoicing/handoff.d.ts +37 -0
- package/dist/invoicing/handoff.js +62 -0
- package/dist/invoicing/index.d.ts +38 -0
- package/dist/invoicing/index.js +36 -0
- package/dist/invoicing/issue.d.ts +39 -0
- package/dist/invoicing/issue.js +55 -0
- package/dist/invoicing/migrations.d.ts +48 -0
- package/dist/invoicing/migrations.js +25 -0
- package/dist/invoicing/nativeUnsupported.d.ts +9 -0
- package/dist/invoicing/nativeUnsupported.js +22 -0
- package/dist/invoicing/paymentReadback.d.ts +20 -0
- package/dist/invoicing/paymentReadback.js +47 -0
- package/dist/invoicing/postgresInvoiceStore.d.ts +55 -0
- package/dist/invoicing/postgresInvoiceStore.js +344 -0
- package/dist/invoicing/readModel.d.ts +74 -0
- package/dist/invoicing/readModel.js +90 -0
- package/dist/invoicing/readback.d.ts +4 -0
- package/dist/invoicing/readback.js +30 -0
- package/dist/invoicing/reversal.d.ts +37 -0
- package/dist/invoicing/reversal.js +28 -0
- package/dist/invoicing/sellerRevision.d.ts +19 -0
- package/dist/invoicing/sellerRevision.js +14 -0
- package/dist/invoicing/settlementSync.d.ts +18 -0
- package/dist/invoicing/settlementSync.js +32 -0
- package/dist/invoicing/snapshot.d.ts +52 -0
- package/dist/invoicing/snapshot.js +141 -0
- package/dist/subscriptions/commandExamples.d.ts +6 -0
- package/dist/subscriptions/commandExamples.js +20 -0
- package/dist/subscriptions/contracts/commands.d.ts +8 -1
- package/dist/subscriptions/contracts/index.d.ts +2 -2
- package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
- package/dist/subscriptions/index.d.ts +22 -4
- package/dist/subscriptions/index.js +22 -3
- package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
- package/dist/subscriptions/lifecycleMessages.js +38 -0
- package/dist/subscriptions/paymentOutcomes.js +1 -1
- package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
- package/dist/subscriptions/postgresCommandStore.js +172 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
- package/dist/subscriptions/postgresRetryStore.js +11 -0
- package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
- package/dist/subscriptions/postgresStockWaitStore.js +174 -0
- package/dist/subscriptions/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -0
- package/package.json +3 -3
- package/release-gates.json +21 -7
- package/sql/20260809210000_accounting_document_rail.sql +889 -0
- package/sql/20260811150000_accounting_payout_rail.sql +333 -0
- package/sql/20260812130000_accounting_document_lifecycle_rail.sql +460 -0
- package/sql/20261022190000_invoicing_durable_duties.sql +494 -0
- package/sql/20261022191000_invoicing_document_effects.sql +249 -0
- package/sql/20261022192000_invoicing_payment_decoupling.sql +144 -0
- package/sql/20261022195000_invoicing_seller_revision.sql +65 -0
- package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
- package/sql/20261024180000_subscription_stock_wait.sql +330 -0
- package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
- package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
- package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
- package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
- package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
- package/src/composition/index.ts +1 -0
- package/src/composition/subscriptionContribution.ts +25 -0
- package/src/fulfillment/contracts/dispatch.ts +4 -0
- package/src/fulfillment/dispatchProjection.ts +4 -2
- package/src/fulfillment/omnipack/client.ts +13 -6
- package/src/fulfillment/omnipack/index.ts +5 -2
- package/src/fulfillment/providerDispatchWorker.ts +14 -1
- package/src/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- package/src/invoicing/accountingCanonicalProviderLines.ts +134 -0
- package/src/invoicing/accountingLineAllocation.ts +69 -0
- package/src/invoicing/adapterConformance.ts +249 -0
- package/src/invoicing/artifacts.ts +19 -0
- package/src/invoicing/bindings.ts +80 -0
- package/src/invoicing/channelPolicy.ts +50 -0
- package/src/invoicing/continuity.ts +15 -0
- package/src/invoicing/contracts/facts.ts +12 -1
- package/src/invoicing/contracts/host.ts +3 -4
- package/src/invoicing/contracts/index.ts +1 -1
- package/src/invoicing/contracts/store.ts +11 -7
- package/src/invoicing/contribution.ts +28 -0
- package/src/invoicing/correction.ts +56 -0
- package/src/invoicing/delivery.ts +122 -0
- package/src/invoicing/documentHistory.ts +301 -0
- package/src/invoicing/documentSync.ts +51 -0
- package/src/invoicing/dutyCollector.ts +31 -0
- package/src/invoicing/fulfillmentTrigger.ts +144 -0
- package/src/invoicing/handoff.ts +71 -0
- package/src/invoicing/index.ts +38 -0
- package/src/invoicing/issue.ts +64 -0
- package/src/invoicing/migrations.ts +25 -0
- package/src/invoicing/nativeUnsupported.ts +24 -0
- package/src/invoicing/paymentReadback.ts +70 -0
- package/src/invoicing/postgresInvoiceStore.ts +334 -0
- package/src/invoicing/readModel.ts +80 -0
- package/src/invoicing/readback.ts +26 -0
- package/src/invoicing/reversal.ts +34 -0
- package/src/invoicing/sellerRevision.ts +20 -0
- package/src/invoicing/settlementSync.ts +37 -0
- package/src/invoicing/snapshot.ts +192 -0
- package/src/subscriptions/commandExamples.ts +19 -0
- package/src/subscriptions/contracts/commands.ts +35 -2
- package/src/subscriptions/contracts/index.ts +2 -2
- package/src/subscriptions/contracts/stockWait.ts +14 -2
- package/src/subscriptions/index.ts +30 -3
- package/src/subscriptions/lifecycleMessages.ts +37 -0
- package/src/subscriptions/paymentOutcomes.ts +1 -1
- package/src/subscriptions/postgresCommandStore.ts +183 -0
- package/src/subscriptions/postgresRetryStore.ts +11 -0
- package/src/subscriptions/postgresStockWaitStore.ts +170 -0
- 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
|
|
25
|
-
*
|
|
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
|
|
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").
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
76
|
-
//
|
|
77
|
-
// must
|
|
78
|
-
//
|
|
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] : []),
|
package/src/composition/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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 })
|
|
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;
|
package/src/inventory/index.ts
CHANGED
|
@@ -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
|
+
}
|