@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.
- package/CHANGELOG.md +222 -99
- package/README.md +59 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +95 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +17 -7
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +104 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
- package/dist/fulfillment/providerDispatchWorker.js +47 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +100 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +246 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +267 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/index.d.ts +1 -0
- package/dist/invoicing/index.js +1 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +22 -2
- package/release-gates.json +56 -3
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +20 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +92 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +19 -7
- package/src/fulfillment/omnipack/index.ts +96 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +48 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +60 -0
- package/src/invoicing/contracts/host.ts +85 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +98 -0
- package/src/invoicing/index.ts +1 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- 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(
|
|
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,
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 },
|
|
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 ??=
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 {};
|