@openlup/platform 0.19.0 → 0.21.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 +161 -231
- package/README.md +182 -8
- package/dist/composition/composeBundle.d.ts +6 -0
- package/dist/composition/composeBundle.js +2 -0
- package/dist/composition/index.js +1 -0
- package/dist/payments/adapterConformance.d.ts +8 -0
- package/dist/payments/adapterConformance.js +109 -0
- package/dist/payments/bindings.d.ts +3 -0
- package/dist/payments/bindings.js +17 -0
- package/dist/payments/checkoutActivePaymentActionResolver.d.ts +7 -0
- package/dist/payments/checkoutActivePaymentActionResolver.js +77 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.d.ts +8 -0
- package/dist/payments/checkoutRecoveryPaymentResolver.js +87 -0
- package/dist/payments/contracts/adapter.d.ts +113 -0
- package/dist/payments/contracts/adapter.js +1 -0
- package/dist/payments/contracts/conformance.d.ts +155 -0
- package/dist/payments/contracts/conformance.js +1 -0
- package/dist/payments/contracts/evidence.d.ts +157 -0
- package/dist/payments/contracts/evidence.js +1 -0
- package/dist/payments/contracts/index.d.ts +10 -0
- package/dist/payments/contracts/index.js +1 -0
- package/dist/payments/contracts/preparedAttempt.d.ts +69 -0
- package/dist/payments/contracts/preparedAttempt.js +1 -0
- package/dist/payments/contracts/reconciliation.d.ts +303 -0
- package/dist/payments/contracts/reconciliation.js +1 -0
- package/dist/payments/contracts/repair.d.ts +92 -0
- package/dist/payments/contracts/repair.js +1 -0
- package/dist/payments/contracts/truth.d.ts +92 -0
- package/dist/payments/contracts/truth.js +1 -0
- package/dist/payments/eventConformanceRunner.d.ts +12 -0
- package/dist/payments/eventConformanceRunner.js +125 -0
- package/dist/payments/index.d.ts +16 -0
- package/dist/payments/index.js +16 -0
- package/dist/payments/paymentAttemptSilenceWindow.d.ts +10 -0
- package/dist/payments/paymentAttemptSilenceWindow.js +19 -0
- package/dist/payments/paymentMethodLifecycle.d.ts +11 -0
- package/dist/payments/paymentMethodLifecycle.js +37 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.d.ts +7 -0
- package/dist/payments/paymentProviderReconciliationAbsenceReopen.js +100 -0
- package/dist/payments/paymentProviderReconciliationEvidence.d.ts +36 -0
- package/dist/payments/paymentProviderReconciliationEvidence.js +91 -0
- package/dist/payments/paymentProviderReconciliationWorker.d.ts +4 -0
- package/dist/payments/paymentProviderReconciliationWorker.js +245 -0
- package/dist/payments/paymentTruth.d.ts +8 -0
- package/dist/payments/paymentTruth.js +125 -0
- package/dist/payments/paymentVerifyNowHandler.d.ts +27 -0
- package/dist/payments/paymentVerifyNowHandler.js +70 -0
- package/dist/payments/paymentVerifyNowService.d.ts +23 -0
- package/dist/payments/paymentVerifyNowService.js +93 -0
- package/dist/payments/paymentWebhookPostProcessing.d.ts +22 -0
- package/dist/payments/paymentWebhookPostProcessing.js +15 -0
- package/dist/payments/postgresPaymentTruth.d.ts +10 -0
- package/dist/payments/postgresPaymentTruth.js +126 -0
- package/dist/payments/preparedAttempt.d.ts +6 -0
- package/dist/payments/preparedAttempt.js +70 -0
- package/dist/payments/preparedAttemptFinalization.d.ts +5 -0
- package/dist/payments/preparedAttemptFinalization.js +12 -0
- package/dist/payments/schemaCapabilities.d.ts +10 -0
- package/dist/payments/schemaCapabilities.js +10 -0
- package/dist/payments/sql.d.ts +20 -0
- package/dist/payments/sql.js +6 -0
- package/dist/payments/stripe/classifyDecline.d.ts +46 -0
- package/dist/payments/stripe/classifyDecline.js +58 -0
- package/dist/payments/stripe/declineFailureHints.d.ts +46 -0
- package/dist/payments/stripe/declineFailureHints.js +87 -0
- package/dist/payments/stripe/donorContracts.d.ts +120 -0
- package/dist/payments/stripe/donorContracts.js +1 -0
- package/dist/payments/stripe/executionDecline.d.ts +53 -0
- package/dist/payments/stripe/executionDecline.js +107 -0
- package/dist/payments/stripe/index.d.ts +16 -0
- package/dist/payments/stripe/index.js +239 -0
- package/dist/payments/stripe/intentMapper.d.ts +26 -0
- package/dist/payments/stripe/intentMapper.js +51 -0
- package/dist/payments/stripe/methodFacts.d.ts +10 -0
- package/dist/payments/stripe/methodFacts.js +12 -0
- package/dist/payments/stripe/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/stripe/paymentFailureEvidence.js +33 -0
- package/dist/payments/stripe/stripeFailureEvidence.d.ts +41 -0
- package/dist/payments/stripe/stripeFailureEvidence.js +82 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.d.ts +28 -0
- package/dist/payments/stripe/stripeIntentConfirmationEvidence.js +32 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.d.ts +35 -0
- package/dist/payments/stripe/stripePaymentReconciliationProvider.js +222 -0
- package/dist/payments/stripe/stripeRefundEvidence.d.ts +8 -0
- package/dist/payments/stripe/stripeRefundEvidence.js +17 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.d.ts +45 -0
- package/dist/payments/stripe/stripeSandboxPaymentExecutionAdapter.js +220 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.d.ts +14 -0
- package/dist/payments/stripe/stripeWebhookNormalizer.js +243 -0
- package/dist/payments/stripe/webhookSignature.d.ts +14 -0
- package/dist/payments/stripe/webhookSignature.js +43 -0
- package/dist/payments/tpay/blikRecurringMapper.d.ts +37 -0
- package/dist/payments/tpay/blikRecurringMapper.js +24 -0
- package/dist/payments/tpay/capabilities.d.ts +3 -0
- package/dist/payments/tpay/capabilities.js +18 -0
- package/dist/payments/tpay/classifyDecline.d.ts +46 -0
- package/dist/payments/tpay/classifyDecline.js +58 -0
- package/dist/payments/tpay/declineFailureHints.d.ts +72 -0
- package/dist/payments/tpay/declineFailureHints.js +37 -0
- package/dist/payments/tpay/donorContracts.d.ts +140 -0
- package/dist/payments/tpay/donorContracts.js +1 -0
- package/dist/payments/tpay/index.d.ts +43 -0
- package/dist/payments/tpay/index.js +264 -0
- package/dist/payments/tpay/paymentFailureEvidence.d.ts +26 -0
- package/dist/payments/tpay/paymentFailureEvidence.js +33 -0
- package/dist/payments/tpay/preparedProviderAttempt.d.ts +22 -0
- package/dist/payments/tpay/preparedProviderAttempt.js +24 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.d.ts +3 -0
- package/dist/payments/tpay/providerAttemptFailureDiagnostic.js +95 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.d.ts +25 -0
- package/dist/payments/tpay/tpayCreateTransactionInput.js +128 -0
- package/dist/payments/tpay/tpayDeclineEvidence.d.ts +126 -0
- package/dist/payments/tpay/tpayDeclineEvidence.js +177 -0
- package/dist/payments/tpay/tpayDispatchFailure.d.ts +60 -0
- package/dist/payments/tpay/tpayDispatchFailure.js +121 -0
- package/dist/payments/tpay/tpayFailureEvidence.d.ts +20 -0
- package/dist/payments/tpay/tpayFailureEvidence.js +61 -0
- package/dist/payments/tpay/tpayHttpClient.d.ts +52 -0
- package/dist/payments/tpay/tpayHttpClient.js +185 -0
- package/dist/payments/tpay/tpayHttpFailure.d.ts +57 -0
- package/dist/payments/tpay/tpayHttpFailure.js +143 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.d.ts +20 -0
- package/dist/payments/tpay/tpayPaymentExecutionAdapter.js +249 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.d.ts +5 -0
- package/dist/payments/tpay/tpayPaymentReconciliationProvider.js +244 -0
- package/dist/payments/tpay/tpayTransactionPayload.d.ts +120 -0
- package/dist/payments/tpay/tpayTransactionPayload.js +96 -0
- package/dist/payments/tpay/wallClockTime.d.ts +31 -0
- package/dist/payments/tpay/wallClockTime.js +57 -0
- package/dist/payments/tpay/webhookJwsVerifier.d.ts +17 -0
- package/dist/payments/tpay/webhookJwsVerifier.js +51 -0
- package/dist/payments/tpay/webhookNormalizer.d.ts +54 -0
- package/dist/payments/tpay/webhookNormalizer.js +200 -0
- package/dist/payments/verifiedEvidenceProjection.d.ts +12 -0
- package/dist/payments/verifiedEvidenceProjection.js +166 -0
- package/dist/payments/verifiedIngress.d.ts +119 -0
- package/dist/payments/verifiedIngress.js +220 -0
- package/dist/shipping/bindings.d.ts +5 -0
- package/dist/shipping/bindings.js +12 -2
- package/dist/shipping/index.d.ts +14 -0
- package/dist/shipping/index.js +5 -0
- package/package.json +22 -2
- package/release-gates.json +64 -3
- package/sql/20260810180000_payment_settlement_rail.sql +574 -0
- package/sql/20260815150000_payment_truth_reconciliation.sql +479 -0
- package/sql/20260826100000_late_payment_shifts_the_subscription_cycle.sql +215 -0
- package/sql/20261021200000_shipping_flat_rate_seed.sql +1 -0
- package/src/composition/composeBundle.ts +9 -0
- package/src/composition/index.ts +1 -0
- package/src/payments/adapterConformance.ts +118 -0
- package/src/payments/bindings.ts +17 -0
- package/src/payments/checkoutActivePaymentActionResolver.ts +97 -0
- package/src/payments/checkoutRecoveryPaymentResolver.ts +108 -0
- package/src/payments/contracts/adapter.ts +96 -0
- package/src/payments/contracts/conformance.ts +171 -0
- package/src/payments/contracts/evidence.ts +142 -0
- package/src/payments/contracts/index.ts +54 -0
- package/src/payments/contracts/preparedAttempt.ts +86 -0
- package/src/payments/contracts/reconciliation.ts +296 -0
- package/src/payments/contracts/repair.ts +95 -0
- package/src/payments/contracts/truth.ts +108 -0
- package/src/payments/eventConformanceRunner.ts +149 -0
- package/src/payments/index.ts +21 -0
- package/src/payments/paymentAttemptSilenceWindow.ts +26 -0
- package/src/payments/paymentMethodLifecycle.ts +50 -0
- package/src/payments/paymentProviderReconciliationAbsenceReopen.ts +130 -0
- package/src/payments/paymentProviderReconciliationEvidence.ts +117 -0
- package/src/payments/paymentProviderReconciliationWorker.ts +291 -0
- package/src/payments/paymentTruth.ts +131 -0
- package/src/payments/paymentVerifyNowHandler.ts +94 -0
- package/src/payments/paymentVerifyNowService.ts +125 -0
- package/src/payments/paymentWebhookPostProcessing.ts +36 -0
- package/src/payments/postgresPaymentTruth.ts +149 -0
- package/src/payments/preparedAttempt.ts +90 -0
- package/src/payments/preparedAttemptFinalization.ts +13 -0
- package/src/payments/schemaCapabilities.ts +10 -0
- package/src/payments/sql.ts +6 -0
- package/src/payments/stripe/classifyDecline.ts +62 -0
- package/src/payments/stripe/declineFailureHints.ts +89 -0
- package/src/payments/stripe/donorContracts.ts +87 -0
- package/src/payments/stripe/executionDecline.ts +126 -0
- package/src/payments/stripe/index.ts +235 -0
- package/src/payments/stripe/intentMapper.ts +58 -0
- package/src/payments/stripe/methodFacts.ts +21 -0
- package/src/payments/stripe/paymentFailureEvidence.ts +58 -0
- package/src/payments/stripe/stripeFailureEvidence.ts +115 -0
- package/src/payments/stripe/stripeIntentConfirmationEvidence.ts +34 -0
- package/src/payments/stripe/stripePaymentReconciliationProvider.ts +263 -0
- package/src/payments/stripe/stripeRefundEvidence.ts +19 -0
- package/src/payments/stripe/stripeSandboxPaymentExecutionAdapter.ts +283 -0
- package/src/payments/stripe/stripeWebhookNormalizer.ts +277 -0
- package/src/payments/stripe/webhookSignature.ts +55 -0
- package/src/payments/tpay/blikRecurringMapper.ts +42 -0
- package/src/payments/tpay/capabilities.ts +21 -0
- package/src/payments/tpay/classifyDecline.ts +62 -0
- package/src/payments/tpay/declineFailureHints.ts +90 -0
- package/src/payments/tpay/donorContracts.ts +104 -0
- package/src/payments/tpay/index.ts +257 -0
- package/src/payments/tpay/paymentFailureEvidence.ts +58 -0
- package/src/payments/tpay/preparedProviderAttempt.ts +38 -0
- package/src/payments/tpay/providerAttemptFailureDiagnostic.ts +99 -0
- package/src/payments/tpay/tpayCreateTransactionInput.ts +158 -0
- package/src/payments/tpay/tpayDeclineEvidence.ts +233 -0
- package/src/payments/tpay/tpayDispatchFailure.ts +186 -0
- package/src/payments/tpay/tpayFailureEvidence.ts +75 -0
- package/src/payments/tpay/tpayHttpClient.ts +290 -0
- package/src/payments/tpay/tpayHttpFailure.ts +186 -0
- package/src/payments/tpay/tpayPaymentExecutionAdapter.ts +297 -0
- package/src/payments/tpay/tpayPaymentReconciliationProvider.ts +285 -0
- package/src/payments/tpay/tpayTransactionPayload.ts +207 -0
- package/src/payments/tpay/wallClockTime.ts +57 -0
- package/src/payments/tpay/webhookJwsVerifier.ts +82 -0
- package/src/payments/tpay/webhookNormalizer.ts +279 -0
- package/src/payments/verifiedEvidenceProjection.ts +179 -0
- package/src/payments/verifiedIngress.ts +317 -0
- package/src/shipping/bindings.ts +18 -2
- package/src/shipping/index.ts +6 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export type PaymentTruthOutcome = "captured" | "refused" | "indeterminate";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How a refusal looked to the settlement source that reported it.
|
|
8
|
+
*
|
|
9
|
+
* NOT the retry taxonomy. `PaymentFailureClass` in `@openlup/core/payment` is a
|
|
10
|
+
* different vocabulary with no value in common: that one answers "may this be
|
|
11
|
+
* retried, and how", and is what `commerce_payment_attempts.failure_class`
|
|
12
|
+
* persists. This one answers "what did the source say happened" and never leaves
|
|
13
|
+
* this module's reconciliation ledger. They shared a name until this wave, which
|
|
14
|
+
* meant an import could quietly resolve to the wrong five strings.
|
|
15
|
+
*/
|
|
16
|
+
export type PaymentTruthRefusalClass =
|
|
17
|
+
| "authorization_refused"
|
|
18
|
+
| "insufficient_funds"
|
|
19
|
+
| "method_unavailable"
|
|
20
|
+
| "risk_refused"
|
|
21
|
+
| "unknown_refusal";
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
export type PaymentSourceEvidence = {
|
|
25
|
+
sourceKind: "accepted_event" | "reconciliation_read";
|
|
26
|
+
sourceReference: string;
|
|
27
|
+
observedStatus: string;
|
|
28
|
+
observedAt: string;
|
|
29
|
+
payloadFingerprint: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export type PaymentTruthEvent = {
|
|
34
|
+
sourceEventId: string;
|
|
35
|
+
idempotencyKey: string;
|
|
36
|
+
settlementIntentId: string;
|
|
37
|
+
outcome: PaymentTruthOutcome;
|
|
38
|
+
amountMinor: number;
|
|
39
|
+
currency: string;
|
|
40
|
+
occurredAt: string;
|
|
41
|
+
failure: { classification: PaymentTruthRefusalClass; reason: string | null } | null;
|
|
42
|
+
evidence: PaymentSourceEvidence;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
export type PaymentReconciliationResult = {
|
|
47
|
+
eventId: string;
|
|
48
|
+
idempotencyKey: string;
|
|
49
|
+
evidenceStatus: "observed" | "failed";
|
|
50
|
+
outcome: PaymentTruthOutcome;
|
|
51
|
+
occurredAt: string;
|
|
52
|
+
failure: PaymentTruthEvent["failure"];
|
|
53
|
+
evidence: PaymentSourceEvidence;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
export type PaymentTruthReadback = {
|
|
58
|
+
event: {
|
|
59
|
+
id: string;
|
|
60
|
+
sourceEventId: string;
|
|
61
|
+
fingerprint: string;
|
|
62
|
+
state: "open" | "settled";
|
|
63
|
+
outcome: PaymentTruthOutcome;
|
|
64
|
+
replayed: boolean;
|
|
65
|
+
};
|
|
66
|
+
effects: {
|
|
67
|
+
paymentResultRecorded: boolean;
|
|
68
|
+
dunningOpened: boolean;
|
|
69
|
+
accountingRequested: boolean;
|
|
70
|
+
};
|
|
71
|
+
financial: {
|
|
72
|
+
settlementStatus: string | null;
|
|
73
|
+
orderStatus: string | null;
|
|
74
|
+
subscriptionStatus: string | null;
|
|
75
|
+
accountingStatus: string | null;
|
|
76
|
+
amountMinor: number;
|
|
77
|
+
currency: string;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
export interface PaymentTruthPort {
|
|
83
|
+
ingestEvent(input: PaymentTruthEvent): Promise<PaymentTruthReadback>;
|
|
84
|
+
recordReconciliation(input: PaymentReconciliationResult): Promise<PaymentTruthReadback>;
|
|
85
|
+
sweepOpenEvents(input: { now: string; limit: number }): Promise<{
|
|
86
|
+
claimed: number;
|
|
87
|
+
settledIgnored: number;
|
|
88
|
+
eventIds: string[];
|
|
89
|
+
}>;
|
|
90
|
+
readEvent(eventId: string): Promise<PaymentTruthReadback | null>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
export type CanonicalPaymentSource = {
|
|
95
|
+
sourceEventId: string;
|
|
96
|
+
eventKind: string;
|
|
97
|
+
settlementIntentId: string | null;
|
|
98
|
+
amountMinor: number | null;
|
|
99
|
+
currency: string | null;
|
|
100
|
+
occurredAt: string | null;
|
|
101
|
+
payloadFingerprint: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type PostgresPaymentTruthOptions = {
|
|
105
|
+
dunningTemplateSlug: string;
|
|
106
|
+
recoveryUrlPath: string;
|
|
107
|
+
recoveryTemplateSlug: string;
|
|
108
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { PaymentExecutionBaseInput } from "@openlup/core/testing";
|
|
2
|
+
import type { ExecutionCapability, SimulatorResult, ExecutionSubject, ExecutionProviderIdentity, ExecutionEvidence, AggregateKey, ResultStatus, DurableEventCounts, DurableEventAggregate, DurableEventFixtureCardinality, DurableEventWriteCardinality, DurableEventCardinalityExpectations, DurableEventSnapshot, EventReceipt, DurableEventCapability, DurableEventEvidence } from "./contracts/conformance.js";
|
|
3
|
+
import {
|
|
4
|
+
validatePaymentExecutionContractResult,
|
|
5
|
+
} from "@openlup/core/testing";
|
|
6
|
+
|
|
7
|
+
/** Framework-free execution proof; durable event evidence is a separate capability. */
|
|
8
|
+
export async function runExecutionConformance<
|
|
9
|
+
TInput extends PaymentExecutionBaseInput,
|
|
10
|
+
TResult extends SimulatorResult,
|
|
11
|
+
>(input: ExecutionProviderIdentity & {
|
|
12
|
+
readonly captured: ExecutionSubject<TInput, TResult>;
|
|
13
|
+
readonly capturedLedger: { cardinality(): number };
|
|
14
|
+
readonly refused: ExecutionSubject<TInput, TResult>;
|
|
15
|
+
readonly request: TInput;
|
|
16
|
+
}): Promise<ExecutionEvidence> {
|
|
17
|
+
assertEqual(input.capturedLedger.cardinality(), 0, "initial captured ledger cardinality");
|
|
18
|
+
const captured = await input.captured.execute(input.request);
|
|
19
|
+
const refused = await input.refused.execute(input.request);
|
|
20
|
+
for (const result of [captured, refused]) {
|
|
21
|
+
const validation = validatePaymentExecutionContractResult({
|
|
22
|
+
input: input.request,
|
|
23
|
+
result,
|
|
24
|
+
expectedProvider: input.expectedProvider,
|
|
25
|
+
});
|
|
26
|
+
if (!validation.ok || validation.issues.length) {
|
|
27
|
+
throw new Error(`Invalid simulator payment execution result: ${JSON.stringify(validation.issues)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
assertEqual(captured.responsePayload.outcome, "captured", "captured subject outcome");
|
|
31
|
+
assertEqual(refused.responsePayload.outcome, "refused", "refused subject outcome");
|
|
32
|
+
const results = [captured, refused];
|
|
33
|
+
const providerCalls = results.filter((result) => result.responsePayload.providerCall === true).length;
|
|
34
|
+
const capturedEffects = input.capturedLedger.cardinality();
|
|
35
|
+
const permanentRefusals = results.filter((result) => result.responsePayload.outcome === "refused"
|
|
36
|
+
&& result.providerDecline?.code === "payment_failed" && result.providerDecline.mandateUnsupported === false).length;
|
|
37
|
+
if (capturedEffects !== 1 || permanentRefusals !== 1) {
|
|
38
|
+
throw new Error("Expected one captured effect and one permanent simulator refusal.");
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
capability: { name: "simulator-execution", durabilityClaim: false },
|
|
42
|
+
executions: results.length,
|
|
43
|
+
providerCalls,
|
|
44
|
+
capturedEffects,
|
|
45
|
+
permanentRefusals,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runDurableEventConformance(
|
|
50
|
+
subject: DurableEventCapability,
|
|
51
|
+
expected: DurableEventCardinalityExpectations,
|
|
52
|
+
): Promise<DurableEventEvidence> {
|
|
53
|
+
subject.assertSafe();
|
|
54
|
+
let evidence: Omit<DurableEventEvidence, "cleanupResidualRows"> | undefined;
|
|
55
|
+
try {
|
|
56
|
+
assertEqual((await subject.reset()).residualRows, 0, "baseline residual rows");
|
|
57
|
+
await subject.seed();
|
|
58
|
+
const baseline = await subject.snapshot();
|
|
59
|
+
assertCoreCounts(baseline.counts, expected.fixture, expected.baseline);
|
|
60
|
+
|
|
61
|
+
const aSuccess = await deliver(subject, "outOfOrder", "a-success", "succeeded", "2026-07-29T10:00:00.000Z");
|
|
62
|
+
const afterOutOfOrderSuccess = await subject.snapshot();
|
|
63
|
+
assertEvent(afterOutOfOrderSuccess, aSuccess, "a-success", "outOfOrder", "processed");
|
|
64
|
+
assertCoreCounts(afterOutOfOrderSuccess.counts, expected.fixture, expected.afterOutOfOrderSuccess);
|
|
65
|
+
assertSucceeded(afterOutOfOrderSuccess.aggregates.outOfOrder);
|
|
66
|
+
|
|
67
|
+
const aFailure = await deliver(subject, "outOfOrder", "a-failure", "failed", "2026-07-29T09:00:00.000Z");
|
|
68
|
+
const afterOutOfOrderFailure = await subject.snapshot();
|
|
69
|
+
assertEvent(afterOutOfOrderFailure, aFailure, "a-failure", "outOfOrder", "ignored");
|
|
70
|
+
assertCoreCounts(afterOutOfOrderFailure.counts, expected.fixture, expected.afterOutOfOrderFailure);
|
|
71
|
+
assertSucceeded(afterOutOfOrderFailure.aggregates.outOfOrder);
|
|
72
|
+
assertStableAggregate(baseline.aggregates.outOfOrder, afterOutOfOrderFailure.aggregates.outOfOrder);
|
|
73
|
+
|
|
74
|
+
const duplicate = await deliver(subject, "outOfOrder", "a-success", "succeeded", "2026-07-29T10:00:00.000Z");
|
|
75
|
+
assertEqual(duplicate.ingest.replayed, true, "duplicate ingest replay");
|
|
76
|
+
assertEqual(duplicate.apply.replayed, true, "duplicate apply replay");
|
|
77
|
+
assertEqual(duplicate.ingest.paymentEventId, aSuccess.ingest.paymentEventId, "duplicate canonical event id");
|
|
78
|
+
const afterDuplicate = await subject.snapshot();
|
|
79
|
+
assertEvent(afterDuplicate, duplicate, "a-success", "outOfOrder", "processed");
|
|
80
|
+
assertCoreCounts(afterDuplicate.counts, expected.fixture, expected.afterDuplicate);
|
|
81
|
+
assertSucceeded(afterDuplicate.aggregates.outOfOrder);
|
|
82
|
+
assertStableAggregate(afterOutOfOrderFailure.aggregates.outOfOrder, afterDuplicate.aggregates.outOfOrder);
|
|
83
|
+
|
|
84
|
+
const bFailure = await deliver(subject, "lateSuccess", "b-failure", "failed", "2026-07-29T09:30:00.000Z");
|
|
85
|
+
const afterLateFailure = await subject.snapshot();
|
|
86
|
+
assertEvent(afterLateFailure, bFailure, "b-failure", "lateSuccess", "processed");
|
|
87
|
+
assertCoreCounts(afterLateFailure.counts, expected.fixture, expected.afterLateFailure);
|
|
88
|
+
assertEqual(JSON.stringify(Object.values(afterLateFailure.aggregates.lateSuccess).slice(4)), JSON.stringify(["pending_payment", "failed", "failed", "failed"]), "late failure statuses");
|
|
89
|
+
|
|
90
|
+
const bSuccess = await deliver(subject, "lateSuccess", "b-success", "succeeded", "2026-07-29T10:30:00.000Z");
|
|
91
|
+
const final = await subject.snapshot();
|
|
92
|
+
assertEvent(final, bSuccess, "b-success", "lateSuccess", "processed");
|
|
93
|
+
assertCoreCounts(final.counts, expected.fixture, expected.final);
|
|
94
|
+
assertSucceeded(final.aggregates.lateSuccess);
|
|
95
|
+
assertStableAggregate(baseline.aggregates.lateSuccess, final.aggregates.lateSuccess);
|
|
96
|
+
evidence = {
|
|
97
|
+
capability: { name: subject.name, durabilityClaim: true },
|
|
98
|
+
baseline,
|
|
99
|
+
afterOutOfOrderSuccess,
|
|
100
|
+
afterOutOfOrderFailure,
|
|
101
|
+
afterDuplicate,
|
|
102
|
+
afterLateFailure,
|
|
103
|
+
final,
|
|
104
|
+
};
|
|
105
|
+
} finally {
|
|
106
|
+
const cleanup = await subject.reset();
|
|
107
|
+
assertEqual(cleanup.residualRows, 0, "cleanup residual rows");
|
|
108
|
+
}
|
|
109
|
+
if (!evidence) throw new Error("Payment-event conformance produced no evidence");
|
|
110
|
+
return { ...evidence, cleanupResidualRows: 0 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function deliver(subject: DurableEventCapability, aggregate: AggregateKey, eventKey: string, resultStatus: ResultStatus, occurredAt: string) {
|
|
114
|
+
const ingest = await subject.ingest({ aggregate, eventKey, resultStatus, occurredAt });
|
|
115
|
+
const apply = await subject.apply({ aggregate, eventKey, paymentEventId: ingest.paymentEventId, resultStatus, occurredAt });
|
|
116
|
+
return { ingest, apply };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function assertCoreCounts(counts: DurableEventCounts, fixture: DurableEventFixtureCardinality, writes: DurableEventWriteCardinality): void {
|
|
120
|
+
for (const [key, value] of Object.entries({ ...fixture, ...writes })) {
|
|
121
|
+
assertEqual(counts[key as keyof DurableEventCounts], value, `${key} count`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function assertEvent(snapshot: DurableEventSnapshot, receipt: { ingest: EventReceipt }, eventKey: string, aggregate: AggregateKey, expectedState: string): void {
|
|
126
|
+
const event = snapshot.events.find((candidate) => candidate.eventKey === eventKey);
|
|
127
|
+
if (!event) throw new Error(`Missing durable event ${eventKey}`);
|
|
128
|
+
assertEqual(event.id, receipt.ingest.paymentEventId, `${eventKey} canonical event id`);
|
|
129
|
+
assertEqual(event.paymentIntentId, snapshot.aggregates[aggregate].paymentIntentId, `${eventKey} intent id`);
|
|
130
|
+
assertEqual(event.paymentAttemptId, snapshot.aggregates[aggregate].paymentAttemptId, `${eventKey} attempt id`);
|
|
131
|
+
assertEqual(event.processingStatus, expectedState, `${eventKey} processing status`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertStableAggregate(before: DurableEventAggregate, after: DurableEventAggregate): void {
|
|
135
|
+
for (const key of ["orderId", "paymentId", "paymentIntentId", "paymentAttemptId"] as const) {
|
|
136
|
+
assertEqual(after[key], before[key], `stable ${key}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function assertSucceeded(aggregate: DurableEventAggregate): void {
|
|
141
|
+
assertEqual(aggregate.orderStatus, "paid", "order status");
|
|
142
|
+
assertEqual(aggregate.paymentStatus, "succeeded", "payment status");
|
|
143
|
+
assertEqual(aggregate.intentStatus, "succeeded", "intent status");
|
|
144
|
+
assertEqual(aggregate.attemptStatus, "succeeded", "attempt status");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertEqual(actual: unknown, expected: unknown, label: string): void {
|
|
148
|
+
if (actual !== expected) throw new Error(`${label}: expected ${String(expected)}, received ${String(actual)}`);
|
|
149
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { executePreparedAttemptMechanism } from "./preparedAttempt.js";
|
|
2
|
+
|
|
3
|
+
export { paymentSourceFingerprint, paymentPayloadFingerprint, paymentTruthFingerprint,
|
|
4
|
+
paymentReconciliationFingerprint, assertPaymentTruthEvent } from "./paymentTruth.js";
|
|
5
|
+
export { createVerifiedPaymentIngress } from "./verifiedIngress.js";
|
|
6
|
+
export { readMethodLifecycleEvent } from "./paymentMethodLifecycle.js";
|
|
7
|
+
export { handoffExplicitPaymentRefusal } from "./paymentWebhookPostProcessing.js";
|
|
8
|
+
export { runPaymentProviderReconciliationWorker } from "./paymentProviderReconciliationWorker.js";
|
|
9
|
+
export { verifyPaymentAttemptNow } from "./paymentVerifyNowService.js";
|
|
10
|
+
export { runExecutionConformance, runDurableEventConformance } from "./eventConformanceRunner.js";
|
|
11
|
+
export { runPaymentAdapterConformance } from "./adapterConformance.js";
|
|
12
|
+
|
|
13
|
+
export { createPostgresPaymentTruthPort } from "./postgresPaymentTruth.js";
|
|
14
|
+
export { PAYMENT_MIGRATIONS } from "./sql.js";
|
|
15
|
+
export { paymentSchemaDescriptor } from "./schemaCapabilities.js";
|
|
16
|
+
|
|
17
|
+
export { createCheckoutActivePaymentActionResolver } from "./checkoutActivePaymentActionResolver.js";
|
|
18
|
+
export { createCheckoutRecoveryPaymentResolver } from "./checkoutRecoveryPaymentResolver.js";
|
|
19
|
+
export { createCommercePaymentVerifyHandler } from "./paymentVerifyNowHandler.js";
|
|
20
|
+
|
|
21
|
+
export { createPaymentServices } from "./bindings.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimedPaymentAttempt,
|
|
3
|
+
SilenceWindowLookup,
|
|
4
|
+
} from "./contracts/reconciliation.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether this rail has answered non-terminally for longer than it declares
|
|
8
|
+
* normal. The window comes from the rail's published capability - never a
|
|
9
|
+
* provider identity, never a global constant, because a pushed-event rail and a
|
|
10
|
+
* polled rail have genuinely different normal silences and a self-hoster
|
|
11
|
+
* declares their own. Absent or unusable means never overdue: stay quiet rather
|
|
12
|
+
* than accuse a healthy rail.
|
|
13
|
+
*/
|
|
14
|
+
export function silenceIsOverdue(
|
|
15
|
+
capabilities: SilenceWindowLookup | undefined,
|
|
16
|
+
attempt: ClaimedPaymentAttempt,
|
|
17
|
+
now: string,
|
|
18
|
+
): boolean {
|
|
19
|
+
const minutes = capabilities?.get(attempt.provider)?.terminalOutcomeReporting
|
|
20
|
+
?.silenceBecomesSuspectAfterMinutes;
|
|
21
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) return false;
|
|
22
|
+
const since = Date.parse(attempt.localUpdatedAt);
|
|
23
|
+
const at = Date.parse(now);
|
|
24
|
+
if (!Number.isFinite(since) || !Number.isFinite(at)) return false;
|
|
25
|
+
return at - since >= minutes * 60_000;
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPaymentMethodLifecycleEventKind,
|
|
3
|
+
type PaymentMethodLifecycleEvent,
|
|
4
|
+
type PaymentMethodReplacementFacts,
|
|
5
|
+
} from "@openlup/core/payment";
|
|
6
|
+
import type { NormalizedProviderPaymentWebhook } from "./verifiedIngress.js";
|
|
7
|
+
|
|
8
|
+
export interface ProviderWebhookWithMethodLifecycle extends NormalizedProviderPaymentWebhook {
|
|
9
|
+
methodLifecycle?: PaymentMethodLifecycleEvent | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const METHOD_SCHEME_SNAPSHOT_KEY = "methodScheme";
|
|
13
|
+
export const METHOD_LAST_DIGITS_SNAPSHOT_KEY = "methodLastDigits";
|
|
14
|
+
export const METHOD_EXPIRES_AT_SNAPSHOT_KEY = "methodExpiresAt";
|
|
15
|
+
|
|
16
|
+
export function methodFactSnapshotKeys(
|
|
17
|
+
facts: PaymentMethodReplacementFacts | null | undefined,
|
|
18
|
+
): Record<string, unknown> {
|
|
19
|
+
if (!facts) return {};
|
|
20
|
+
return {
|
|
21
|
+
...(facts.schemeLabel ? { [METHOD_SCHEME_SNAPSHOT_KEY]: facts.schemeLabel } : {}),
|
|
22
|
+
...(facts.lastDigits ? { [METHOD_LAST_DIGITS_SNAPSHOT_KEY]: facts.lastDigits } : {}),
|
|
23
|
+
...(facts.expiresAt ? { [METHOD_EXPIRES_AT_SNAPSHOT_KEY]: facts.expiresAt } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function expiryForMethodRefWrite(
|
|
28
|
+
consentSnapshot: Record<string, unknown> | undefined,
|
|
29
|
+
occurredAt: string,
|
|
30
|
+
active: boolean,
|
|
31
|
+
): string | null {
|
|
32
|
+
const raw = consentSnapshot?.[METHOD_EXPIRES_AT_SNAPSHOT_KEY];
|
|
33
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
34
|
+
const expiry = Date.parse(raw);
|
|
35
|
+
if (!Number.isFinite(expiry)) return null;
|
|
36
|
+
if (!active) return raw;
|
|
37
|
+
const reference = Date.parse(occurredAt);
|
|
38
|
+
return Number.isFinite(reference) && expiry <= reference ? null : raw;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readMethodLifecycleEvent(
|
|
42
|
+
event: NormalizedProviderPaymentWebhook,
|
|
43
|
+
): PaymentMethodLifecycleEvent | null {
|
|
44
|
+
const candidate = (event as ProviderWebhookWithMethodLifecycle).methodLifecycle;
|
|
45
|
+
if (!candidate || typeof candidate !== "object") return null;
|
|
46
|
+
if (!isPaymentMethodLifecycleEventKind(candidate.kind)) return null;
|
|
47
|
+
if (!candidate.providerKind?.trim() || !candidate.providerMethodRef?.trim()) return null;
|
|
48
|
+
if (!candidate.providerEventId?.trim() || !candidate.occurredAt?.trim()) return null;
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
absenceProbeEvidenceKey,
|
|
3
|
+
absenceReopenKey,
|
|
4
|
+
safeMessage,
|
|
5
|
+
} from "./paymentProviderReconciliationEvidence.js";
|
|
6
|
+
import type {
|
|
7
|
+
ClaimedPaymentAttempt,
|
|
8
|
+
PaymentProviderReconciliationProvider,
|
|
9
|
+
PaymentProviderReconciliationWorkerInput,
|
|
10
|
+
} from "./contracts/reconciliation.js";
|
|
11
|
+
|
|
12
|
+
// Mirrors the RPC's v_min_stranded_age guard (migration 20260711153000). The
|
|
13
|
+
// worker pre-checks so an under-age attempt is a silent skip for this pass
|
|
14
|
+
// instead of a guaranteed `prepared_attempt_absence_reopen_too_fresh` reject.
|
|
15
|
+
const MIN_PREPARED_ABSENCE_REOPEN_AGE_SECONDS = 30 * 60;
|
|
16
|
+
|
|
17
|
+
/** Reopen only after genuine provider-side absence proof. A missing local
|
|
18
|
+
* acknowledgement cannot exclude a charge before reference persistence failed;
|
|
19
|
+
* minting a fresh idempotency key in that crash window could double-charge.
|
|
20
|
+
* Providers without an exact absence probe require operator review.
|
|
21
|
+
*/
|
|
22
|
+
export async function tryReopenPreparedAbsence(
|
|
23
|
+
input: PaymentProviderReconciliationWorkerInput,
|
|
24
|
+
attempt: ClaimedPaymentAttempt,
|
|
25
|
+
now: string,
|
|
26
|
+
): Promise<boolean> {
|
|
27
|
+
// Reopen schedules another subscription-cycle charge. One-time prepared
|
|
28
|
+
// attempts remain operator-manual so this path can never create a blind
|
|
29
|
+
// second provider call for them.
|
|
30
|
+
if (attempt.orderMode !== "subscription_cycle" || !attempt.subscriptionCycleId) return false;
|
|
31
|
+
const subscriptionCycleId = attempt.subscriptionCycleId;
|
|
32
|
+
|
|
33
|
+
const provider: PaymentProviderReconciliationProvider | undefined = input.providers[attempt.provider];
|
|
34
|
+
|
|
35
|
+
// Without an exact absence probe, stranded attempts stay operator-manual.
|
|
36
|
+
// Guessing absence could schedule a second charge.
|
|
37
|
+
if (!provider?.findPaymentByLocalIntent) return false;
|
|
38
|
+
|
|
39
|
+
const ageMs = Date.parse(now) - Date.parse(attempt.localUpdatedAt);
|
|
40
|
+
if (!Number.isFinite(ageMs) || ageMs < MIN_PREPARED_ABSENCE_REOPEN_AGE_SECONDS * 1000) {
|
|
41
|
+
// Under the RPC's stranded-age guard — the next configured reconciliation pass catches it.
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let probe: "found" | "absent";
|
|
46
|
+
try {
|
|
47
|
+
probe = await provider.findPaymentByLocalIntent({ attempt });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
await recordProbeEvidence(input, attempt, now, "absence_probe_failed", {
|
|
50
|
+
probeError: safeMessage(error),
|
|
51
|
+
operatorReviewRequired: true,
|
|
52
|
+
});
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (probe === "found") {
|
|
57
|
+
// Crash-after-charge window: the provider HAS a payment for this intent
|
|
58
|
+
// but our attempt never recorded it. Reopening would double-charge —
|
|
59
|
+
// leave it for operator ref-repair; the stale-attempt readback path takes
|
|
60
|
+
// over once refs are restored.
|
|
61
|
+
await recordProbeEvidence(input, attempt, now, "provider_payment_found_after_prepare", {
|
|
62
|
+
providerAbsenceConfirmed: false,
|
|
63
|
+
operatorReviewRequired: true,
|
|
64
|
+
reopenBlocked: "provider_payment_exists",
|
|
65
|
+
});
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await input.port.reopenPreparedAttemptAfterAbsence({
|
|
71
|
+
idempotencyKey: absenceReopenKey(attempt),
|
|
72
|
+
paymentAttemptId: attempt.paymentAttemptId,
|
|
73
|
+
expectedPaymentIntentId: attempt.paymentIntentId,
|
|
74
|
+
expectedSubscriptionCycleId: subscriptionCycleId,
|
|
75
|
+
operatorRef: "payment-provider-reconciliation@auto",
|
|
76
|
+
absenceCheckedAt: now,
|
|
77
|
+
nextRetryAt: now,
|
|
78
|
+
absenceEvidence: {
|
|
79
|
+
providerAbsenceConfirmed: true,
|
|
80
|
+
source: "payment-provider-reconciliation.auto-reopen.v0",
|
|
81
|
+
basis: `${attempt.provider}_intent_correlated_search_absent`,
|
|
82
|
+
probedAt: now,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
// The RPC records its own 'prepared_attempt_provider_absent' corrected
|
|
86
|
+
// evidence, cancels the attempt, and requeues the cycle — nothing more to
|
|
87
|
+
// write here.
|
|
88
|
+
return true;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const message = safeMessage(error);
|
|
91
|
+
// The RPC's 22023 precondition rejects (too_fresh, provider_event_exists,
|
|
92
|
+
// terminal reconciliation, state drift since claim, ...) are expected
|
|
93
|
+
// races, not run failures — the attempt stays operator-visible either way.
|
|
94
|
+
await recordProbeEvidence(
|
|
95
|
+
input,
|
|
96
|
+
attempt,
|
|
97
|
+
now,
|
|
98
|
+
message.includes("prepared_attempt_absence_reopen_")
|
|
99
|
+
? "absence_reopen_precondition_reject"
|
|
100
|
+
: "absence_reopen_failed",
|
|
101
|
+
{ reopenError: message, operatorReviewRequired: true },
|
|
102
|
+
);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function recordProbeEvidence(
|
|
108
|
+
input: PaymentProviderReconciliationWorkerInput,
|
|
109
|
+
attempt: ClaimedPaymentAttempt,
|
|
110
|
+
now: string,
|
|
111
|
+
providerStatus: string,
|
|
112
|
+
extra: Record<string, unknown>,
|
|
113
|
+
): Promise<{ replayed: boolean }> {
|
|
114
|
+
return input.port.recordEvidence({
|
|
115
|
+
idempotencyKey: absenceProbeEvidenceKey(attempt, now),
|
|
116
|
+
provider: attempt.provider,
|
|
117
|
+
providerPaymentId: null,
|
|
118
|
+
paymentIntentId: attempt.paymentIntentId,
|
|
119
|
+
paymentAttemptId: attempt.paymentAttemptId,
|
|
120
|
+
localStatus: attempt.attemptStatus,
|
|
121
|
+
providerStatus,
|
|
122
|
+
correctionStatus: "observed",
|
|
123
|
+
checkedAt: now,
|
|
124
|
+
payload: {
|
|
125
|
+
source: "payment-provider-reconciliation.auto-reopen.v0",
|
|
126
|
+
provider: attempt.provider,
|
|
127
|
+
...extra,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimedPaymentAttempt,
|
|
3
|
+
ProviderReconciliationStatus,
|
|
4
|
+
ProviderReconciliationStatusKind,
|
|
5
|
+
} from "./contracts/reconciliation.js";
|
|
6
|
+
|
|
7
|
+
export function evidencePayload(
|
|
8
|
+
attempt: ClaimedPaymentAttempt,
|
|
9
|
+
providerStatus: ProviderReconciliationStatus,
|
|
10
|
+
extra: Record<string, unknown>,
|
|
11
|
+
): Record<string, unknown> {
|
|
12
|
+
return {
|
|
13
|
+
source: "payment-provider-reconciliation.v0",
|
|
14
|
+
provider: attempt.provider,
|
|
15
|
+
providerPaymentId: attempt.providerPaymentId,
|
|
16
|
+
paymentIntentId: attempt.paymentIntentId,
|
|
17
|
+
paymentAttemptId: attempt.paymentAttemptId,
|
|
18
|
+
orderId: attempt.orderId,
|
|
19
|
+
subscriptionId: attempt.subscriptionId,
|
|
20
|
+
subscriptionCycleId: attempt.subscriptionCycleId,
|
|
21
|
+
localAttemptStatus: attempt.attemptStatus,
|
|
22
|
+
localIntentStatus: attempt.intentStatus,
|
|
23
|
+
providerStatus: providerStatus.providerStatus,
|
|
24
|
+
normalizedStatus: providerStatus.status,
|
|
25
|
+
failureReason: providerStatus.failureReason,
|
|
26
|
+
amountMinorPresent: providerStatus.amountMinor !== null,
|
|
27
|
+
currency: providerStatus.currency,
|
|
28
|
+
providerPayload: providerStatus.rawPayload,
|
|
29
|
+
...extra,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function terminalAmountMismatch(
|
|
34
|
+
attempt: ClaimedPaymentAttempt,
|
|
35
|
+
status: ProviderReconciliationStatus,
|
|
36
|
+
): string | null {
|
|
37
|
+
if (status.amountMinor !== null && status.amountMinor !== attempt.amountMinor) {
|
|
38
|
+
return "provider_amount_mismatch";
|
|
39
|
+
}
|
|
40
|
+
if (status.currency && status.currency.toUpperCase() !== attempt.currency.toUpperCase()) {
|
|
41
|
+
return "provider_currency_mismatch";
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function applyResultKey(attempt: ClaimedPaymentAttempt, status: "succeeded" | "failed"): string {
|
|
47
|
+
return `payment-provider-reconciliation:${attempt.paymentAttemptId}:apply:${status}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function observedEvidenceKey(
|
|
51
|
+
attempt: ClaimedPaymentAttempt,
|
|
52
|
+
status: ProviderReconciliationStatusKind,
|
|
53
|
+
correction: "observed" | "failed",
|
|
54
|
+
checkedAt: string,
|
|
55
|
+
): string {
|
|
56
|
+
return `payment-provider-reconciliation:${attempt.paymentAttemptId}:${correction}:${status}:${bucket(checkedAt)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function preparedAttemptEvidenceKey(attempt: ClaimedPaymentAttempt, checkedAt: string): string {
|
|
60
|
+
return `payment-provider-reconciliation:${attempt.paymentAttemptId}:failed:prepared_without_provider_ack:${bucket(checkedAt)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Stable (no time bucket): a successful reopen cancels the attempt (it leaves
|
|
64
|
+
// `created`), so the RPC is called at most once per attempt; replays after a
|
|
65
|
+
// crash return the recorded response via commerce_idempotency_keys.
|
|
66
|
+
export function absenceReopenKey(attempt: ClaimedPaymentAttempt): string {
|
|
67
|
+
return `payment-provider-reconciliation:${attempt.paymentAttemptId}:absence-reopen`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function absenceProbeEvidenceKey(attempt: ClaimedPaymentAttempt, checkedAt: string): string {
|
|
71
|
+
return `payment-provider-reconciliation:${attempt.paymentAttemptId}:absence-probe:${bucket(checkedAt)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function bucket(value: string): string {
|
|
75
|
+
const ms = Date.parse(value);
|
|
76
|
+
if (!Number.isFinite(ms)) return "unknown";
|
|
77
|
+
return String(Math.floor(ms / (30 * 60 * 1000)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
81
|
+
if (!Number.isFinite(value)) return min;
|
|
82
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function safeMessage(error: unknown): string {
|
|
86
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 240);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The class the reading adapter already decided, read back off the normalized
|
|
91
|
+
* status so the terminal write can carry it.
|
|
92
|
+
*
|
|
93
|
+
* READ, never re-derived. Both shipped adapters classify what they read through
|
|
94
|
+
* the shared `classifyDecline` seam and put the verdict in `rawPayload`; deriving
|
|
95
|
+
* a second one here would be a second classifier, free to disagree with the one
|
|
96
|
+
* whose reasoning the evidence payload records. It is also why this function is
|
|
97
|
+
* provider-agnostic: it never learns which rail refused, only what that rail
|
|
98
|
+
* concluded.
|
|
99
|
+
*
|
|
100
|
+
* Returns a spreadable fragment rather than a nullable value, so an unclassified
|
|
101
|
+
* refusal leaves the key genuinely ABSENT from the composed call instead of
|
|
102
|
+
* present-and-null. Absence is what the port's contract defines as "behave
|
|
103
|
+
* exactly as before the field existed", and it is what the two non-classifying
|
|
104
|
+
* consumers of that contract are pinned to.
|
|
105
|
+
*
|
|
106
|
+
* A success carries no class by construction: there is no refusal to classify.
|
|
107
|
+
*/
|
|
108
|
+
export function classificationOf(
|
|
109
|
+
resultStatus: "succeeded" | "failed",
|
|
110
|
+
providerStatus: ProviderReconciliationStatus,
|
|
111
|
+
): { failureClassification?: { failureClass: string; decidedBy: string } } {
|
|
112
|
+
if (resultStatus !== "failed") return {};
|
|
113
|
+
const { failureClass, failureClassDecidedBy } = providerStatus.rawPayload;
|
|
114
|
+
if (typeof failureClass !== "string" || failureClass.length === 0) return {};
|
|
115
|
+
if (typeof failureClassDecidedBy !== "string" || failureClassDecidedBy.length === 0) return {};
|
|
116
|
+
return { failureClassification: { failureClass, decidedBy: failureClassDecidedBy } };
|
|
117
|
+
}
|