@openlup/platform 0.21.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 +432 -146
- package/README.md +82 -589
- package/dist/composition/composeBundle.d.ts +7 -0
- package/dist/composition/composeBundle.js +2 -0
- package/dist/composition/fulfillmentContribution.d.ts +21 -0
- package/dist/composition/fulfillmentContribution.js +19 -0
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/fulfillment/adapterConformance.d.ts +3 -0
- package/dist/fulfillment/adapterConformance.js +223 -0
- package/dist/fulfillment/bindings.d.ts +3 -0
- package/dist/fulfillment/bindings.js +29 -0
- package/dist/fulfillment/commerceFulfillmentHandlers.d.ts +29 -0
- package/dist/fulfillment/commerceFulfillmentHandlers.js +184 -0
- package/dist/fulfillment/commerceFulfillmentReadModel.d.ts +86 -0
- package/dist/fulfillment/commerceFulfillmentReadModel.js +131 -0
- package/dist/fulfillment/contracts/alignment.d.ts +25 -0
- package/dist/fulfillment/contracts/alignment.js +1 -0
- package/dist/fulfillment/contracts/bindings.d.ts +51 -0
- package/dist/fulfillment/contracts/bindings.js +1 -0
- package/dist/fulfillment/contracts/capabilities.d.ts +29 -0
- package/dist/fulfillment/contracts/capabilities.js +1 -0
- package/dist/fulfillment/contracts/conformance.d.ts +95 -0
- package/dist/fulfillment/contracts/conformance.js +1 -0
- package/dist/fulfillment/contracts/customerAlignment.d.ts +6 -0
- package/dist/fulfillment/contracts/customerAlignment.js +1 -0
- package/dist/fulfillment/contracts/dispatch.d.ts +143 -0
- package/dist/fulfillment/contracts/dispatch.js +1 -0
- package/dist/fulfillment/contracts/index.d.ts +18 -0
- package/dist/fulfillment/contracts/index.js +1 -0
- package/dist/fulfillment/contracts/label.d.ts +35 -0
- package/dist/fulfillment/contracts/label.js +1 -0
- package/dist/fulfillment/contracts/nativeUnsupported.d.ts +6 -0
- package/dist/fulfillment/contracts/nativeUnsupported.js +1 -0
- package/dist/fulfillment/contracts/observation.d.ts +72 -0
- package/dist/fulfillment/contracts/observation.js +1 -0
- package/dist/fulfillment/contracts/operator.d.ts +64 -0
- package/dist/fulfillment/contracts/operator.js +1 -0
- package/dist/fulfillment/contracts/read.d.ts +110 -0
- package/dist/fulfillment/contracts/read.js +1 -0
- package/dist/fulfillment/contracts/readback.d.ts +59 -0
- package/dist/fulfillment/contracts/readback.js +1 -0
- package/dist/fulfillment/contracts/replacement.d.ts +34 -0
- package/dist/fulfillment/contracts/replacement.js +1 -0
- package/dist/fulfillment/contracts/store.d.ts +89 -0
- package/dist/fulfillment/contracts/store.js +1 -0
- package/dist/fulfillment/contracts/sync.d.ts +226 -0
- package/dist/fulfillment/contracts/sync.js +1 -0
- package/dist/fulfillment/currentFulfillmentParcel.d.ts +2 -0
- package/dist/fulfillment/currentFulfillmentParcel.js +1 -0
- package/dist/fulfillment/delayReadModel.d.ts +11 -0
- package/dist/fulfillment/delayReadModel.js +45 -0
- package/dist/fulfillment/deliveryAlignmentReadModel.d.ts +4 -0
- package/dist/fulfillment/deliveryAlignmentReadModel.js +12 -0
- package/dist/fulfillment/dispatchProjection.d.ts +26 -0
- package/dist/fulfillment/dispatchProjection.js +107 -0
- package/dist/fulfillment/fulfillmentObservationEffects.d.ts +66 -0
- package/dist/fulfillment/fulfillmentObservationEffects.js +36 -0
- package/dist/fulfillment/handlerSchemas.d.ts +647 -0
- package/dist/fulfillment/handlerSchemas.js +213 -0
- package/dist/fulfillment/handlerSupport.d.ts +33 -0
- package/dist/fulfillment/handlerSupport.js +25 -0
- package/dist/fulfillment/index.d.ts +38 -0
- package/dist/fulfillment/index.js +37 -0
- package/dist/fulfillment/labelLifecycle.d.ts +24 -0
- package/dist/fulfillment/labelLifecycle.js +9 -0
- package/dist/fulfillment/lowStockEvidenceHandler.d.ts +36 -0
- package/dist/fulfillment/lowStockEvidenceHandler.js +46 -0
- package/dist/fulfillment/migrations.d.ts +32 -0
- package/dist/fulfillment/migrations.js +9 -0
- package/dist/fulfillment/nativeUnsupported.d.ts +10 -0
- package/dist/fulfillment/nativeUnsupported.js +18 -0
- package/dist/fulfillment/normalizedFulfillmentFacts.d.ts +2 -0
- package/dist/fulfillment/normalizedFulfillmentFacts.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +32 -0
- package/dist/fulfillment/observationProjection.js +90 -0
- package/dist/fulfillment/omnipack/capabilities.d.ts +3 -0
- package/dist/fulfillment/omnipack/capabilities.js +16 -0
- package/dist/fulfillment/omnipack/client.d.ts +39 -0
- package/dist/fulfillment/omnipack/client.js +172 -0
- package/dist/fulfillment/omnipack/index.d.ts +55 -0
- package/dist/fulfillment/omnipack/index.js +408 -0
- package/dist/fulfillment/omnipack/mappers.d.ts +79 -0
- package/dist/fulfillment/omnipack/mappers.js +199 -0
- package/dist/fulfillment/omnipack/observation.d.ts +28 -0
- package/dist/fulfillment/omnipack/observation.js +72 -0
- package/dist/fulfillment/omnipack/omnipackDispatchPayload.d.ts +36 -0
- package/dist/fulfillment/omnipack/omnipackDispatchPayload.js +63 -0
- package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.d.ts +83 -0
- package/dist/fulfillment/omnipack/omnipackOutboundOrderPayload.js +113 -0
- package/dist/fulfillment/omnipack/omnipackProductPackPayload.d.ts +11 -0
- package/dist/fulfillment/omnipack/omnipackProductPackPayload.js +38 -0
- package/dist/fulfillment/omnipack/omnipackProductPayload.d.ts +18 -0
- package/dist/fulfillment/omnipack/omnipackProductPayload.js +51 -0
- package/dist/fulfillment/omnipack/omnipackReconciliationPayload.d.ts +1 -0
- package/dist/fulfillment/omnipack/omnipackReconciliationPayload.js +3 -0
- package/dist/fulfillment/omnipack/omnipackStatusVocabulary.d.ts +4 -0
- package/dist/fulfillment/omnipack/omnipackStatusVocabulary.js +87 -0
- package/dist/fulfillment/omnipack/omnipackWebhookPayload.d.ts +9 -0
- package/dist/fulfillment/omnipack/omnipackWebhookPayload.js +76 -0
- package/dist/fulfillment/omnipack/productSyncMappers.d.ts +18 -0
- package/dist/fulfillment/omnipack/productSyncMappers.js +43 -0
- package/dist/fulfillment/omnipack/providerError.d.ts +25 -0
- package/dist/fulfillment/omnipack/providerError.js +47 -0
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/omnipack/trackingReferences.d.ts +16 -0
- package/dist/fulfillment/omnipack/trackingReferences.js +52 -0
- package/dist/fulfillment/paidFulfillmentRecovery.d.ts +67 -0
- package/dist/fulfillment/paidFulfillmentRecovery.js +154 -0
- package/dist/fulfillment/paidHandoff.d.ts +27 -0
- package/dist/fulfillment/paidHandoff.js +86 -0
- package/dist/fulfillment/paidHandoffPorts.d.ts +24 -0
- package/dist/fulfillment/paidHandoffPorts.js +3 -0
- package/dist/fulfillment/postgresDeliveryAlignment.d.ts +4 -0
- package/dist/fulfillment/postgresDeliveryAlignment.js +12 -0
- package/dist/fulfillment/postgresDispatchStore.d.ts +16 -0
- package/dist/fulfillment/postgresDispatchStore.js +44 -0
- package/dist/fulfillment/postgresEvidenceReadPort.d.ts +25 -0
- package/dist/fulfillment/postgresEvidenceReadPort.js +45 -0
- package/dist/fulfillment/postgresPaidFulfillmentRecovery.d.ts +25 -0
- package/dist/fulfillment/postgresPaidFulfillmentRecovery.js +26 -0
- package/dist/fulfillment/postgresPaidHandoffStore.d.ts +4 -0
- package/dist/fulfillment/postgresPaidHandoffStore.js +10 -0
- package/dist/fulfillment/postgresShipmentSpine.d.ts +70 -0
- package/dist/fulfillment/postgresShipmentSpine.js +246 -0
- package/dist/fulfillment/postgresStockSyncPort.d.ts +8 -0
- package/dist/fulfillment/postgresStockSyncPort.js +66 -0
- package/dist/fulfillment/productReconciliationEvidenceHandler.d.ts +36 -0
- package/dist/fulfillment/productReconciliationEvidenceHandler.js +50 -0
- package/dist/fulfillment/providerCapabilities.d.ts +9 -0
- package/dist/fulfillment/providerCapabilities.js +17 -0
- package/dist/fulfillment/providerCommandLedger.d.ts +143 -0
- package/dist/fulfillment/providerCommandLedger.js +14 -0
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +27 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +29 -0
- package/dist/fulfillment/providerDispatchError.d.ts +6 -0
- package/dist/fulfillment/providerDispatchError.js +5 -0
- package/dist/fulfillment/providerDispatchWorker.d.ts +44 -0
- package/dist/fulfillment/providerDispatchWorker.js +199 -0
- package/dist/fulfillment/providerProductSyncWorker.d.ts +28 -0
- package/dist/fulfillment/providerProductSyncWorker.js +122 -0
- package/dist/fulfillment/providerReconciliationError.d.ts +9 -0
- package/dist/fulfillment/providerReconciliationError.js +17 -0
- package/dist/fulfillment/providerReconciliationTerminalSkip.d.ts +2 -0
- package/dist/fulfillment/providerReconciliationTerminalSkip.js +6 -0
- package/dist/fulfillment/providerReconciliationTimestamp.d.ts +1 -0
- package/dist/fulfillment/providerReconciliationTimestamp.js +27 -0
- package/dist/fulfillment/providerReconciliationWorker.d.ts +90 -0
- package/dist/fulfillment/providerReconciliationWorker.js +207 -0
- package/dist/fulfillment/providerStockAvailability.d.ts +13 -0
- package/dist/fulfillment/providerStockAvailability.js +51 -0
- package/dist/fulfillment/providerStockSyncWorker.d.ts +41 -0
- package/dist/fulfillment/providerStockSyncWorker.js +295 -0
- package/dist/fulfillment/providerTrackingReferences.d.ts +16 -0
- package/dist/fulfillment/providerTrackingReferences.js +23 -0
- package/dist/fulfillment/replacementRefusals.d.ts +5 -0
- package/dist/fulfillment/replacementRefusals.js +38 -0
- package/dist/fulfillment/replacementShipmentHandler.d.ts +5 -0
- package/dist/fulfillment/replacementShipmentHandler.js +46 -0
- package/dist/fulfillment/schemaCapabilities.d.ts +13 -0
- package/dist/fulfillment/schemaCapabilities.js +7 -0
- package/dist/fulfillment/shipmentsOverviewHandler.d.ts +16 -0
- package/dist/fulfillment/shipmentsOverviewHandler.js +54 -0
- package/dist/fulfillment/statusMap.d.ts +2 -0
- package/dist/fulfillment/statusMap.js +1 -0
- package/dist/fulfillment/trackingProgress.d.ts +1 -0
- package/dist/fulfillment/trackingProgress.js +1 -0
- package/dist/fulfillment/verifiedIngress.d.ts +79 -0
- package/dist/fulfillment/verifiedIngress.js +94 -0
- 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/payments/stripe/index.d.ts +25 -0
- package/dist/payments/stripe/index.js +25 -0
- package/dist/payments/tpay/index.d.ts +30 -0
- package/dist/payments/tpay/index.js +30 -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 +37 -2
- package/release-gates.json +97 -3
- package/sql/20260808210000_fulfillment_shipment_rail.sql +810 -0
- package/sql/20260819150000_fulfillment_shipment_sequence.sql +98 -0
- package/sql/20261022080000_fulfillment_command_ledger.sql +786 -0
- package/sql/20261022090000_fulfillment_delivery_alignment.sql +1106 -0
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/src/composition/composeBundle.ts +4 -0
- package/src/composition/fulfillmentContribution.ts +28 -0
- package/src/composition/index.ts +1 -0
- package/src/fulfillment/adapterConformance.ts +195 -0
- package/src/fulfillment/bindings.ts +26 -0
- package/src/fulfillment/commerceFulfillmentHandlers.ts +263 -0
- package/src/fulfillment/commerceFulfillmentReadModel.ts +257 -0
- package/src/fulfillment/contracts/alignment.ts +13 -0
- package/src/fulfillment/contracts/bindings.ts +30 -0
- package/src/fulfillment/contracts/capabilities.ts +15 -0
- package/src/fulfillment/contracts/conformance.ts +63 -0
- package/src/fulfillment/contracts/customerAlignment.ts +4 -0
- package/src/fulfillment/contracts/dispatch.ts +73 -0
- package/src/fulfillment/contracts/index.ts +18 -0
- package/src/fulfillment/contracts/label.ts +9 -0
- package/src/fulfillment/contracts/nativeUnsupported.ts +6 -0
- package/src/fulfillment/contracts/observation.ts +26 -0
- package/src/fulfillment/contracts/operator.ts +31 -0
- package/src/fulfillment/contracts/read.ts +42 -0
- package/src/fulfillment/contracts/readback.ts +24 -0
- package/src/fulfillment/contracts/replacement.ts +15 -0
- package/src/fulfillment/contracts/store.ts +27 -0
- package/src/fulfillment/contracts/sync.ts +72 -0
- package/src/fulfillment/currentFulfillmentParcel.ts +2 -0
- package/src/fulfillment/delayReadModel.ts +60 -0
- package/src/fulfillment/deliveryAlignmentReadModel.ts +15 -0
- package/src/fulfillment/dispatchProjection.ts +105 -0
- package/src/fulfillment/fulfillmentObservationEffects.ts +86 -0
- package/src/fulfillment/handlerSchemas.ts +255 -0
- package/src/fulfillment/handlerSupport.ts +25 -0
- package/src/fulfillment/index.ts +39 -0
- package/src/fulfillment/labelLifecycle.ts +11 -0
- package/src/fulfillment/lowStockEvidenceHandler.ts +60 -0
- package/src/fulfillment/migrations.ts +9 -0
- package/src/fulfillment/nativeUnsupported.ts +23 -0
- package/src/fulfillment/normalizedFulfillmentFacts.ts +2 -0
- package/src/fulfillment/observationProjection.ts +92 -0
- package/src/fulfillment/omnipack/capabilities.ts +18 -0
- package/src/fulfillment/omnipack/client.ts +270 -0
- package/src/fulfillment/omnipack/index.ts +369 -0
- package/src/fulfillment/omnipack/mappers.ts +282 -0
- package/src/fulfillment/omnipack/observation.ts +79 -0
- package/src/fulfillment/omnipack/omnipackDispatchPayload.ts +93 -0
- package/src/fulfillment/omnipack/omnipackOutboundOrderPayload.ts +194 -0
- package/src/fulfillment/omnipack/omnipackProductPackPayload.ts +47 -0
- package/src/fulfillment/omnipack/omnipackProductPayload.ts +67 -0
- package/src/fulfillment/omnipack/omnipackReconciliationPayload.ts +7 -0
- package/src/fulfillment/omnipack/omnipackStatusVocabulary.ts +103 -0
- package/src/fulfillment/omnipack/omnipackWebhookPayload.ts +73 -0
- package/src/fulfillment/omnipack/productSyncMappers.ts +74 -0
- package/src/fulfillment/omnipack/providerError.ts +64 -0
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/omnipack/trackingReferences.ts +77 -0
- package/src/fulfillment/paidFulfillmentRecovery.ts +252 -0
- package/src/fulfillment/paidHandoff.ts +122 -0
- package/src/fulfillment/paidHandoffPorts.ts +21 -0
- package/src/fulfillment/postgresDeliveryAlignment.ts +14 -0
- package/src/fulfillment/postgresDispatchStore.ts +55 -0
- package/src/fulfillment/postgresEvidenceReadPort.ts +59 -0
- package/src/fulfillment/postgresPaidFulfillmentRecovery.ts +29 -0
- package/src/fulfillment/postgresPaidHandoffStore.ts +11 -0
- package/src/fulfillment/postgresShipmentSpine.ts +338 -0
- package/src/fulfillment/postgresStockSyncPort.ts +68 -0
- package/src/fulfillment/productReconciliationEvidenceHandler.ts +64 -0
- package/src/fulfillment/providerCapabilities.ts +18 -0
- package/src/fulfillment/providerCommandLedger.ts +53 -0
- package/src/fulfillment/providerDispatchAcceptance.ts +37 -0
- package/src/fulfillment/providerDispatchError.ts +7 -0
- package/src/fulfillment/providerDispatchWorker.ts +159 -0
- package/src/fulfillment/providerProductSyncWorker.ts +154 -0
- package/src/fulfillment/providerReconciliationError.ts +20 -0
- package/src/fulfillment/providerReconciliationTerminalSkip.ts +8 -0
- package/src/fulfillment/providerReconciliationTimestamp.ts +34 -0
- package/src/fulfillment/providerReconciliationWorker.ts +206 -0
- package/src/fulfillment/providerStockAvailability.ts +60 -0
- package/src/fulfillment/providerStockSyncWorker.ts +318 -0
- package/src/fulfillment/providerTrackingReferences.ts +32 -0
- package/src/fulfillment/replacementRefusals.ts +43 -0
- package/src/fulfillment/replacementShipmentHandler.ts +47 -0
- package/src/fulfillment/schemaCapabilities.ts +7 -0
- package/src/fulfillment/shipmentsOverviewHandler.ts +73 -0
- package/src/fulfillment/statusMap.ts +2 -0
- package/src/fulfillment/trackingProgress.ts +1 -0
- package/src/fulfillment/verifiedIngress.ts +113 -0
- 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/payments/stripe/index.ts +30 -0
- package/src/payments/tpay/index.ts +36 -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,76 @@
|
|
|
1
|
+
import type { RenewalDue, RenewalFrozenSnapshot, RenewalReadback, RenewalSchedule, RenewalStorePort } from "./contracts/renewal.js";
|
|
2
|
+
|
|
3
|
+
/** Transaction-bound executors may be supplied; no driver, environment or account is selected here. */
|
|
4
|
+
export interface SubscriptionQueryExecutor {
|
|
5
|
+
query(text: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[] }>;
|
|
6
|
+
}
|
|
7
|
+
/** The host preserves its native wire snapshot, identity dialect and evidence-bound numbering. */
|
|
8
|
+
export interface NativeRenewalCodec {
|
|
9
|
+
due(row: Record<string, unknown>): RenewalDue;
|
|
10
|
+
freeze(row: Record<string, unknown>, schedule: RenewalSchedule): RenewalFrozenSnapshot;
|
|
11
|
+
encode(snapshot: RenewalFrozenSnapshot): Record<string, unknown>;
|
|
12
|
+
decode(snapshot: Record<string, unknown>): RenewalFrozenSnapshot;
|
|
13
|
+
readback(row: Record<string, unknown>): RenewalReadback;
|
|
14
|
+
identity(snapshot: RenewalFrozenSnapshot): string;
|
|
15
|
+
fingerprint(snapshot: RenewalFrozenSnapshot): string;
|
|
16
|
+
}
|
|
17
|
+
const SNAPSHOT_SQL = `SELECT s.id AS subscription_id,s.client_id,s.next_cycle_at::text AS scheduled_at,t.*,
|
|
18
|
+
COALESCE((SELECT jsonb_agg(jsonb_build_object('lineOrdinal',l.line_ordinal,'sku',l.sku,'quantity',l.quantity,
|
|
19
|
+
'unitAmountMinor',l.unit_amount_minor) ORDER BY l.line_ordinal) FROM public.subscription_renewal_term_lines l
|
|
20
|
+
WHERE l.subscription_id=s.id),'[]'::jsonb) AS lines
|
|
21
|
+
FROM public.subscriptions s JOIN public.subscription_renewal_terms t ON t.subscription_id=s.id
|
|
22
|
+
WHERE s.id=$1::uuid AND s.next_cycle_at=$2::timestamptz`;
|
|
23
|
+
export function subscriptionRecord(value: unknown): Record<string, unknown> {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("subscription_renewal_response_invalid");
|
|
25
|
+
return value as Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
/** Native claim/prepare remain their original SQL transaction units. G23 is composed by the host. */
|
|
28
|
+
export function createPostgresRenewalStore(executor: SubscriptionQueryExecutor, codec: NativeRenewalCodec): RenewalStorePort {
|
|
29
|
+
async function header(input: RenewalSchedule) {
|
|
30
|
+
const result = await executor.query(SNAPSHOT_SQL, [input.subscriptionId, input.scheduledAt]);
|
|
31
|
+
if (!result.rows[0]) throw new Error("subscription_renewal_snapshot_not_found");
|
|
32
|
+
return result.rows[0];
|
|
33
|
+
}
|
|
34
|
+
async function response(sql: string, values: unknown[]) {
|
|
35
|
+
return codec.readback(subscriptionRecord((await executor.query(sql, values)).rows[0]?.response));
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
transactions: { graph: "native", prepare: "cycle_order_items_settlement_reservation_attempt",
|
|
39
|
+
outcome: "settlement_outcome_schedule_counter_reservation", recovery: "settlement_cycle_receipt_case_counter" },
|
|
40
|
+
async listDue(limit, asOf) {
|
|
41
|
+
const result = await executor.query(`SELECT due.*,due.scheduled_at::text AS scheduled_at
|
|
42
|
+
FROM public.subscription_list_due_renewals($1::timestamptz,$2::integer) due`, [asOf, limit]);
|
|
43
|
+
return result.rows.map(row => codec.due(row));
|
|
44
|
+
},
|
|
45
|
+
async buildCycleSnapshots(input) {
|
|
46
|
+
const stored = await executor.query(`SELECT cycle_snapshot FROM public.subscription_renewal_operations
|
|
47
|
+
WHERE subscription_id=$1::uuid AND scheduled_at=$2::timestamptz`, [input.subscriptionId, input.scheduledAt]);
|
|
48
|
+
return stored.rows[0] ? codec.decode(subscriptionRecord(stored.rows[0].cycle_snapshot)) : codec.freeze(await header(input), input);
|
|
49
|
+
},
|
|
50
|
+
async readMandateEvidence(input) {
|
|
51
|
+
const row = await header(input);
|
|
52
|
+
return { authorized: true, authorizationRef: String(row.authorization_ref),
|
|
53
|
+
authorizedAt: row.unattended_charge_authorized_at instanceof Date
|
|
54
|
+
? row.unattended_charge_authorized_at.toISOString() : String(row.unattended_charge_authorized_at) };
|
|
55
|
+
},
|
|
56
|
+
async admitDeliveryAlignment(input) {
|
|
57
|
+
const row = await header(input);
|
|
58
|
+
if (row.delivery_admission !== "allowed" && row.delivery_admission !== "blocked") throw new Error("subscription_renewal_delivery_admission_invalid");
|
|
59
|
+
return row.delivery_admission === "allowed"
|
|
60
|
+
? { allowed: true, state: "static", reason: "allowed" }
|
|
61
|
+
: { allowed: false, state: "static", reason: String(row.delivery_block_reason ?? "delivery_alignment_blocked"), deferred: false };
|
|
62
|
+
},
|
|
63
|
+
async claimDue(input) {
|
|
64
|
+
return response(`SELECT public.subscription_claim_due_renewal($1::text,$2::text,$3::jsonb,$4::text,$5::integer,$6::timestamptz) AS response`,
|
|
65
|
+
[codec.identity(input.snapshot), codec.fingerprint(input.snapshot), JSON.stringify(codec.encode(input.snapshot)), input.workerId, input.leaseSeconds ?? 300, input.now]);
|
|
66
|
+
},
|
|
67
|
+
async readRenewal(identityKey) {
|
|
68
|
+
const value = (await executor.query("SELECT public.subscription_read_renewal($1::text) AS response", [identityKey])).rows[0]?.response;
|
|
69
|
+
return value == null ? null : codec.readback(subscriptionRecord(value));
|
|
70
|
+
},
|
|
71
|
+
async refuseBeforePayment(input) {
|
|
72
|
+
return response("SELECT public.subscription_refuse_renewal_preflight($1::uuid,$2::uuid,$3::text,$4::timestamptz) AS response",
|
|
73
|
+
[input.operationId, input.claimToken, input.reason, input.occurredAt]);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { RenewalRetryObligation } from "./contracts/dunning.js";
|
|
2
|
+
import type { RenewalReadback } from "./contracts/renewal.js";
|
|
3
|
+
import { subscriptionRecord, type SubscriptionQueryExecutor } from "./postgresRenewalStore.js";
|
|
4
|
+
|
|
5
|
+
/** N1 accepts only verified M07 evidence; the host supplies logical-cycle correlation explicitly. */
|
|
6
|
+
export interface NativeRetryFailure {
|
|
7
|
+
attemptSequence: number; logicalCycleNumber: number; occurredAt: string;
|
|
8
|
+
failureClass: string; reason: string; externalRef: string | null;
|
|
9
|
+
evidenceKey: string; firstRecordedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface NativeRetryRequest {
|
|
12
|
+
paymentIntentId: string; orderRef: string; amountMinor: number; currency: string;
|
|
13
|
+
idempotencyKey: string; providerIdempotencyKey: string; providerRequestFingerprint: string; paymentMethodRef: string;
|
|
14
|
+
}
|
|
15
|
+
export interface NativeRetryPositive {
|
|
16
|
+
prepareKey?: string; evidenceKey: string; occurredAt: string; firstRecordedAt: string;
|
|
17
|
+
externalRef: string; amountMinor: number; currency: string; template: string;
|
|
18
|
+
}
|
|
19
|
+
export interface NativeRetryPreparation {
|
|
20
|
+
execute: boolean; replayed: boolean; obligationId: string; attemptSequence: number;
|
|
21
|
+
cycleId: string; orderId: string; settlementIntentId: string; identityKey: string;
|
|
22
|
+
operationFingerprint: string; snapshot: Record<string, unknown>; request: NativeRetryRequest;
|
|
23
|
+
}
|
|
24
|
+
export interface NativeRetryRow extends RenewalRetryObligation {
|
|
25
|
+
caseId: string | null; state: "waiting" | "prepared" | "recovered";
|
|
26
|
+
}
|
|
27
|
+
/** No payment execution is implemented here. A replayed prepare always returns execute=false. */
|
|
28
|
+
export function createPostgresRetryStore(executor: SubscriptionQueryExecutor) {
|
|
29
|
+
async function invoke(name: string, values: unknown[]) {
|
|
30
|
+
const result = await executor.query(`SELECT public.${name}(${values.map((_, i) => `$${i + 1}`).join(",")}) AS response`, values);
|
|
31
|
+
return result.rows[0]?.response;
|
|
32
|
+
}
|
|
33
|
+
function row(value: unknown): NativeRetryRow {
|
|
34
|
+
const r = subscriptionRecord(value), snapshot = subscriptionRecord(r.cycle_snapshot);
|
|
35
|
+
if (!["waiting", "prepared", "recovered"].includes(String(r.state))) throw new Error("subscription_retry_state_invalid");
|
|
36
|
+
return { operationId: String(r.renewal_operation_id), subscriptionId: String(r.subscription_id),
|
|
37
|
+
cycleId: String(r.cycle_id), orderId: String(r.order_id), settlementIntentId: String(r.settlement_intent_id),
|
|
38
|
+
logicalCycleNumber: Number(r.logical_cycle_number), nativeRenewalCounter: Number(snapshot.cycleNumber),
|
|
39
|
+
retryAttempt: Number(r.retry_attempt), externalAttemptSequence: Number(r.attempt_sequence),
|
|
40
|
+
nextRetryAt: r.next_retry_at == null ? null : String(r.next_retry_at),
|
|
41
|
+
failureClass: r.failure_class == null ? null : String(r.failure_class),
|
|
42
|
+
failureReason: r.failure_reason == null ? null : String(r.failure_reason), occurredAt: String(r.failed_at),
|
|
43
|
+
identityKey: String(r.identity_key), requestFingerprint: String(r.operation_fingerprint),
|
|
44
|
+
disposition: r.state === "recovered" ? "paid" : r.state === "prepared" || r.next_retry_at != null ? "retry_scheduled" : "payment_failed",
|
|
45
|
+
caseId: r.case_id == null ? null : String(r.case_id), state: r.state as NativeRetryRow["state"] };
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
async read(operationId: string): Promise<NativeRetryRow | null> {
|
|
49
|
+
const value = await invoke("subscription_retry_read", [operationId]); return value == null ? null : row(value);
|
|
50
|
+
},
|
|
51
|
+
async candidates(limit: number, now: string): Promise<NativeRetryRow[]> {
|
|
52
|
+
const value = await invoke("subscription_retry_candidates", [limit, now]);
|
|
53
|
+
if (!Array.isArray(value)) throw new Error("subscription_retry_candidates_invalid");
|
|
54
|
+
return value.map(row);
|
|
55
|
+
},
|
|
56
|
+
async applyFailure(operationId: string, key: string, input: NativeRetryFailure) {
|
|
57
|
+
return row(await invoke("subscription_retry_apply_failure", [operationId, key, input]));
|
|
58
|
+
},
|
|
59
|
+
async openCase(operationId: string, template: string, recoveryPath: string | null) {
|
|
60
|
+
return subscriptionRecord(await invoke("subscription_retry_open_case", [operationId, template, recoveryPath]));
|
|
61
|
+
},
|
|
62
|
+
async authorizeRecovery(operationId: string, tokenId: string, methodRef: string, methodEvidenceRef: string, now: string) {
|
|
63
|
+
return subscriptionRecord(await invoke("subscription_retry_authorize_recovery", [operationId, tokenId, methodRef, methodEvidenceRef, now]));
|
|
64
|
+
},
|
|
65
|
+
async scheduleRecovery(operationId: string, key: string, authorizationId: string, now: string) {
|
|
66
|
+
return subscriptionRecord(await invoke("subscription_retry_schedule_recovery", [operationId, key, authorizationId, now]));
|
|
67
|
+
},
|
|
68
|
+
async prepare(operationId: string, key: string, input: NativeRetryRequest, now: string): Promise<NativeRetryPreparation> {
|
|
69
|
+
return subscriptionRecord(await invoke("subscription_retry_prepare", [operationId, key, input, now])) as unknown as NativeRetryPreparation;
|
|
70
|
+
},
|
|
71
|
+
async recordRecovery(operationId: string, key: string, input: NativeRetryPositive) {
|
|
72
|
+
return row(await invoke("subscription_retry_record_recovery", [operationId, key, input]));
|
|
73
|
+
},
|
|
74
|
+
async noteRowOutcome(operationId: string, key: string, input: Record<string, unknown>) {
|
|
75
|
+
return subscriptionRecord(await invoke("subscription_retry_note_row_outcome", [operationId, key, input]));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface NativeRetryLifecycleBinding {
|
|
81
|
+
executor: SubscriptionQueryExecutor;
|
|
82
|
+
withTransaction<T>(work: (executor: SubscriptionQueryExecutor) => Promise<T>): Promise<T>;
|
|
83
|
+
/** M07's existing transaction-bound native port; never a provider execution port. */
|
|
84
|
+
settlement(executor: SubscriptionQueryExecutor): import("./contracts/renewal.js").RenewalSettlementPort;
|
|
85
|
+
correlate(snapshot: Record<string, unknown>, nativeRenewalCounter: number): import("./contracts/renewal.js").RenewalCycleCorrelation;
|
|
86
|
+
failureTemplate: string;
|
|
87
|
+
recoveryPath: string | null;
|
|
88
|
+
}
|
|
89
|
+
/** Initial handoff opens only the existing donor-keyed case. Terminal outcome and
|
|
90
|
+
* N1 bookkeeping then commit together, so a crash cannot strand that schedule.
|
|
91
|
+
* Subsequent retry attempts use applyFailure/openCase separately, as before. */
|
|
92
|
+
export function createPostgresRetryLifecycleBinding(options: NativeRetryLifecycleBinding): {
|
|
93
|
+
failureHandoff: import("./contracts/dunning.js").RenewalFailureHandoffPort;
|
|
94
|
+
settlement: import("./contracts/renewal.js").RenewalSettlementPort;
|
|
95
|
+
} {
|
|
96
|
+
const base = options.settlement(options.executor);
|
|
97
|
+
return {
|
|
98
|
+
failureHandoff: {
|
|
99
|
+
async handoffFailure(input) {
|
|
100
|
+
const result = await options.executor.query(`SELECT public.dunning_lifecycle_open_case(
|
|
101
|
+
$1::text,$2::uuid,$3::uuid,$4::uuid,$5::integer,$6::timestamptz,$7::text,$8::text,
|
|
102
|
+
$9::timestamptz,$10::bigint,$11::text,$12::text,$13::text) AS response`,
|
|
103
|
+
[input.idempotencyKey, input.subscriptionId, input.cycleId, input.clientId, input.retryAttempt,
|
|
104
|
+
input.nextRetryAt, input.failureClass, input.failureReason, input.occurredAt, input.amountMinor,
|
|
105
|
+
input.currency, options.failureTemplate, options.recoveryPath]);
|
|
106
|
+
const row = subscriptionRecord(result.rows[0]?.response);
|
|
107
|
+
return { caseId: String(row.caseId), replayed: row.replayed === true };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
settlement: {
|
|
111
|
+
prepareRenewal(input) { return base.prepareRenewal(input); },
|
|
112
|
+
acknowledgeExternalAttempt(input) { return base.acknowledgeExternalAttempt(input); },
|
|
113
|
+
async recordTerminalOutcome(input) {
|
|
114
|
+
if (input.outcome !== "refused") return base.recordTerminalOutcome(input);
|
|
115
|
+
return options.withTransaction(async executor => {
|
|
116
|
+
// Own subscription -> operation, the order used by the retry lifecycle.
|
|
117
|
+
await executor.query(`SELECT id FROM public.subscriptions WHERE id=(SELECT subscription_id
|
|
118
|
+
FROM public.subscription_renewal_operations WHERE id=$1::uuid) FOR UPDATE`, [input.operationId]);
|
|
119
|
+
const result = await executor.query(`SELECT op.cycle_snapshot,op.cycle_number,c.id AS case_id,
|
|
120
|
+
c.failure_class,c.failure_reason,c.next_retry_at::text AS next_retry_at,c.opened_at::text AS occurred_at,
|
|
121
|
+
receipt.input AS initial_failure,
|
|
122
|
+
public.subscription_renewal_operation_response(op,false,true,'terminal_replay') AS terminal_response,
|
|
123
|
+
terminal.id IS NOT NULL AND (terminal.reason IS DISTINCT FROM NULLIF(btrim($3::text),'')
|
|
124
|
+
OR terminal.occurred_at IS DISTINCT FROM $4::timestamptz
|
|
125
|
+
OR terminal.external_ref IS DISTINCT FROM $5::text
|
|
126
|
+
OR terminal.operation_fingerprint IS DISTINCT FROM $6::text
|
|
127
|
+
OR op.operation_fingerprint IS DISTINCT FROM $6::text
|
|
128
|
+
OR ($5::text IS NOT NULL AND op.external_attempt_ref IS DISTINCT FROM $5::text)
|
|
129
|
+
OR terminal.outcome <> 'refused') AS conflicting_terminal
|
|
130
|
+
FROM public.subscription_renewal_operations op
|
|
131
|
+
JOIN public.subscription_cycles cycle ON cycle.renewal_operation_id=op.id
|
|
132
|
+
JOIN public.subscription_dunning_cases c ON c.cycle_id=cycle.id
|
|
133
|
+
AND c.idempotency_key=op.identity_key||':failure-handoff'
|
|
134
|
+
LEFT JOIN public.subscription_retry_obligations obligation ON obligation.renewal_operation_id=op.id
|
|
135
|
+
LEFT JOIN public.subscription_retry_receipts receipt ON receipt.obligation_id=obligation.id
|
|
136
|
+
AND receipt.receipt_key=$2::text AND receipt.kind='failure' AND receipt.attempt_sequence=0
|
|
137
|
+
LEFT JOIN public.subscription_renewal_outcomes terminal ON terminal.renewal_operation_id=op.id
|
|
138
|
+
AND terminal.event_key=$2::text
|
|
139
|
+
WHERE op.id=$1::uuid`, [input.operationId, input.eventKey, input.reason ?? null, input.occurredAt,
|
|
140
|
+
input.externalRef ?? null, input.operationFingerprint]);
|
|
141
|
+
const handoff = result.rows[0];
|
|
142
|
+
// Provider-input preflight refusals never opened a customer case.
|
|
143
|
+
if (!handoff) return options.settlement(executor).recordTerminalOutcome(input);
|
|
144
|
+
if (handoff.conflicting_terminal) throw new Error("subscription_retry_replay_conflict");
|
|
145
|
+
// The immutable receipt ends replay before any settlement, rebinding
|
|
146
|
+
// or case propagation. The projection may describe a later attempt.
|
|
147
|
+
if (handoff.initial_failure != null) {
|
|
148
|
+
return subscriptionRecord(handoff.terminal_response) as unknown as RenewalReadback;
|
|
149
|
+
}
|
|
150
|
+
const correlation = options.correlate(subscriptionRecord(handoff.cycle_snapshot), Number(handoff.cycle_number));
|
|
151
|
+
if (correlation.status !== "correlated") throw new Error("subscription_renewal_cycle_correlation_required");
|
|
152
|
+
const recorded = await options.settlement(executor).recordTerminalOutcome(input);
|
|
153
|
+
const retry = createPostgresRetryStore(executor);
|
|
154
|
+
await retry.applyFailure(input.operationId, input.eventKey, {
|
|
155
|
+
attemptSequence: 0, logicalCycleNumber: correlation.logicalCycleNumber,
|
|
156
|
+
occurredAt: String(handoff.occurred_at),
|
|
157
|
+
failureClass: String(handoff.failure_class),
|
|
158
|
+
reason: String(handoff.failure_reason), externalRef: input.externalRef ?? null,
|
|
159
|
+
evidenceKey: input.eventKey, firstRecordedAt: String(handoff.occurred_at),
|
|
160
|
+
});
|
|
161
|
+
// The original handoff owns cadence, including a deliberate null.
|
|
162
|
+
const linked = await executor.query(`UPDATE public.subscription_retry_obligations SET case_id=$2::uuid,
|
|
163
|
+
next_retry_at=CASE WHEN attempt_sequence=0 AND retry_attempt=1 AND state='waiting'
|
|
164
|
+
THEN $3::timestamptz ELSE next_retry_at END
|
|
165
|
+
WHERE renewal_operation_id=$1::uuid AND (case_id IS NULL OR case_id=$2::uuid) RETURNING case_id`,
|
|
166
|
+
[input.operationId, handoff.case_id, handoff.next_retry_at]);
|
|
167
|
+
if (linked.rows.length !== 1) throw new Error("subscription_retry_initial_case_conflict");
|
|
168
|
+
await retry.openCase(input.operationId, options.failureTemplate, options.recoveryPath);
|
|
169
|
+
return recorded;
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { nextRetryAttemptAt } from "@openlup/core/subscription";
|
|
2
|
+
import type {
|
|
3
|
+
CycleChargeFailurePropagationPort, FailurePropagationContext, FailurePropagationResult,
|
|
4
|
+
CycleRetryState,
|
|
5
|
+
} from "./contracts/renewal.js";
|
|
6
|
+
|
|
7
|
+
export interface RenewalFailurePolicy {
|
|
8
|
+
resolveClassification(supplied: FailurePropagationContext["failureClassification"], reason: string): { failureClass: string; decidedBy: string };
|
|
9
|
+
recordKey(key: string): string;
|
|
10
|
+
applyKey(key: string): string;
|
|
11
|
+
dunningKey(key: string, attempt: number): string;
|
|
12
|
+
preparedSuffix: string;
|
|
13
|
+
blockedSuffix: string;
|
|
14
|
+
dunningFailureKey: string;
|
|
15
|
+
logBelowRetry(fields: { cycleId: string; retryAttempt: number }): void;
|
|
16
|
+
logRejected(fields: { cycleId: string; subscriptionId: string; retryAttempt: number; rowOutcomeKey: string; code?: string; reason: string }): void;
|
|
17
|
+
logUnavailable(fields: { cycleId: string; subscriptionId: string; retryAttempt: number; rowOutcomeKey: string; reason: string }): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Complete durable refusal ladder; unexpected port throws remain raw. */
|
|
21
|
+
export async function propagateRenewalFailure(
|
|
22
|
+
port: CycleChargeFailurePropagationPort,
|
|
23
|
+
ctx: FailurePropagationContext,
|
|
24
|
+
policy: RenewalFailurePolicy,
|
|
25
|
+
): Promise<FailurePropagationResult> {
|
|
26
|
+
if (ctx.kind === "off_session_requires_action" && ctx.attemptAlreadyPrepared !== true) {
|
|
27
|
+
return { dunningCaseId: null, retryAttempt: null, applyReplayed: false, dunningPropagationFailed: false };
|
|
28
|
+
}
|
|
29
|
+
if (ctx.kind === "preflight_block" && ctx.attemptAlreadyPrepared !== true) {
|
|
30
|
+
await port.recordBlockedPreflightAttempt({
|
|
31
|
+
idempotencyKey: policy.recordKey(ctx.executionIdempotencyKey),
|
|
32
|
+
paymentIntentId: ctx.paymentIntentId,
|
|
33
|
+
providerKind: ctx.providerKind,
|
|
34
|
+
providerIdempotencyKey: ctx.providerIdempotencyKey,
|
|
35
|
+
providerRequestFingerprint: ctx.providerRequestFingerprint,
|
|
36
|
+
failureReason: ctx.failureReason,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// A replay fingerprints the first durable instant, with caller time only as fallback.
|
|
40
|
+
const occurredAt = await resolveDurableOccurredAt(port, ctx, policy);
|
|
41
|
+
const applied = await port.applyFailedResult({
|
|
42
|
+
idempotencyKey: policy.applyKey(ctx.executionIdempotencyKey),
|
|
43
|
+
paymentIntentId: ctx.paymentIntentId,
|
|
44
|
+
occurredAt,
|
|
45
|
+
failureReason: ctx.failureReason,
|
|
46
|
+
failureClassification: policy.resolveClassification(ctx.failureClassification, ctx.failureReason),
|
|
47
|
+
});
|
|
48
|
+
const cycleSnapshot = await port.readCycleRetryState(ctx.cycleId);
|
|
49
|
+
return {
|
|
50
|
+
...await openDunningCase(port, ctx, cycleSnapshot, occurredAt, policy),
|
|
51
|
+
applyReplayed: applied.replayed,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function resolveDurableOccurredAt(
|
|
55
|
+
port: Pick<CycleChargeFailurePropagationPort, "readDurableAttemptInstant">,
|
|
56
|
+
ctx: FailurePropagationContext,
|
|
57
|
+
policy: RenewalFailurePolicy,
|
|
58
|
+
): Promise<string> {
|
|
59
|
+
const suffix = ctx.attemptAlreadyPrepared === true ? policy.preparedSuffix : policy.blockedSuffix;
|
|
60
|
+
const durable = await port.readDurableAttemptInstant({
|
|
61
|
+
paymentIntentId: ctx.paymentIntentId,
|
|
62
|
+
attemptIdempotencyKey: `${ctx.executionIdempotencyKey}${suffix}`,
|
|
63
|
+
});
|
|
64
|
+
return durable !== null && durable.length > 0 ? durable : ctx.occurredAt;
|
|
65
|
+
}
|
|
66
|
+
async function openDunningCase(
|
|
67
|
+
port: Pick<CycleChargeFailurePropagationPort, "openDunningCase">,
|
|
68
|
+
ctx: FailurePropagationContext,
|
|
69
|
+
cycle: CycleRetryState,
|
|
70
|
+
occurredAt: string,
|
|
71
|
+
policy: RenewalFailurePolicy,
|
|
72
|
+
): Promise<Omit<FailurePropagationResult, "applyReplayed">> {
|
|
73
|
+
const retryAttempt = cycle.retryAttempt ?? 1;
|
|
74
|
+
// Resolve again after the retry read: original context evidence remains dynamic.
|
|
75
|
+
const failureClass = policy.resolveClassification(
|
|
76
|
+
ctx.failureClassification,
|
|
77
|
+
ctx.failureReason,
|
|
78
|
+
).failureClass;
|
|
79
|
+
const stranded = { cycleId: ctx.cycleId, subscriptionId: ctx.subscriptionId, retryAttempt,
|
|
80
|
+
rowOutcomeKey: policy.dunningFailureKey };
|
|
81
|
+
if (retryAttempt < 1) {
|
|
82
|
+
policy.logBelowRetry({ cycleId: ctx.cycleId, retryAttempt });
|
|
83
|
+
return { dunningCaseId: null, retryAttempt, dunningPropagationFailed: false };
|
|
84
|
+
}
|
|
85
|
+
// A stored null is a deliberate no-retry decision, not a missing row.
|
|
86
|
+
const nextRetryAt = cycle.rowPresent
|
|
87
|
+
? cycle.nextRetryAt
|
|
88
|
+
: nextRetryAttemptAt(
|
|
89
|
+
occurredAt,
|
|
90
|
+
retryAttempt,
|
|
91
|
+
undefined,
|
|
92
|
+
failureClass,
|
|
93
|
+
);
|
|
94
|
+
const outcome = await port.openDunningCase({
|
|
95
|
+
idempotencyKey: policy.dunningKey(ctx.executionIdempotencyKey, retryAttempt),
|
|
96
|
+
cycleId: ctx.cycleId,
|
|
97
|
+
subscriptionId: ctx.subscriptionId,
|
|
98
|
+
orderUuid: ctx.orderUuid,
|
|
99
|
+
paymentIntentId: ctx.paymentIntentId,
|
|
100
|
+
retryAttempt,
|
|
101
|
+
nextRetryAt,
|
|
102
|
+
failureReason: ctx.failureReason,
|
|
103
|
+
occurredAt,
|
|
104
|
+
failureClass,
|
|
105
|
+
});
|
|
106
|
+
if (outcome.status === "rejected") {
|
|
107
|
+
policy.logRejected({
|
|
108
|
+
...stranded, code: outcome.code, reason: outcome.reason,
|
|
109
|
+
});
|
|
110
|
+
return { dunningCaseId: null, retryAttempt, dunningPropagationFailed: true };
|
|
111
|
+
}
|
|
112
|
+
if (outcome.status === "unavailable") {
|
|
113
|
+
policy.logUnavailable({
|
|
114
|
+
...stranded, reason: outcome.reason,
|
|
115
|
+
});
|
|
116
|
+
return { dunningCaseId: null, retryAttempt, dunningPropagationFailed: true };
|
|
117
|
+
}
|
|
118
|
+
return { dunningCaseId: outcome.caseId, retryAttempt, dunningPropagationFailed: false };
|
|
119
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PaymentExecutionBaseInput, PaymentExecutionBaseResult, PaymentProviderCapabilityDescriptor,
|
|
3
|
+
ProviderAttemptIdentity,
|
|
4
|
+
} from "@openlup/core/payment";
|
|
5
|
+
import type { RenewalPreparedAttemptPersistencePort } from "./contracts/renewal.js";
|
|
6
|
+
import type {
|
|
7
|
+
HostedRenewalDue, SubscriptionRenewalChargeResult, SubscriptionCycleSnapshotResult,
|
|
8
|
+
SubscriptionCycleOrderResult, RenewalIntentResult, RenewalCycleDeps,
|
|
9
|
+
RenewalExecutionContext, RenewalPrepareInput, RenewalAttemptResult,
|
|
10
|
+
RenewalDeliveryClient, RenewalAllowedAdmission, RenewalDeliveryAdmission,
|
|
11
|
+
} from "./contracts/renewal.js";
|
|
12
|
+
import {
|
|
13
|
+
recordRenewalPreflightBlock, replayRenewalAttempt, declinedRenewalOutcome, requiresActionRenewalOutcome,
|
|
14
|
+
type RenewalPreflightPolicy, type RenewalReplayPolicy, type RenewalOutcomePolicy,
|
|
15
|
+
} from "./renewalCycleRefusalMechanism.js";
|
|
16
|
+
|
|
17
|
+
export interface RenewalCyclePolicy<D extends HostedRenewalDue, CI, IR extends RenewalIntentResult,
|
|
18
|
+
P extends string, I extends PaymentExecutionBaseInput, M extends string | null | undefined, F extends string> {
|
|
19
|
+
validateProvider(due: D): string | null;
|
|
20
|
+
readTotal(snapshot: Record<string, unknown>): number | null;
|
|
21
|
+
failed(due: D, cycleId: string | null, cycleNumber: number | null, orderId: string | null, intentId: string | null, reason: string): SubscriptionRenewalChargeResult;
|
|
22
|
+
cycleKey(due: D): string;
|
|
23
|
+
executionKey(key: string, retry: number, sequence: number): string;
|
|
24
|
+
intentInput(due: D, key: string, snapshots: SubscriptionCycleSnapshotResult, order: SubscriptionCycleOrderResult, total: number): CI;
|
|
25
|
+
identity(due: D, key: string, intent: IR, order: SubscriptionCycleOrderResult, total: number): ProviderAttemptIdentity;
|
|
26
|
+
providerFlow(flow: string): F;
|
|
27
|
+
prepareInput(due: D, context: RenewalExecutionContext<IR, M, F>): RenewalPrepareInput<P>;
|
|
28
|
+
executionInput(due: D, context: RenewalExecutionContext<IR, M, F>, deps: { capability: PaymentProviderCapabilityDescriptor }): I;
|
|
29
|
+
isNotChargeable(error: unknown): boolean;
|
|
30
|
+
skippedNotChargeable(due: D, context: { cycleId: string; cycleNumber: number; orderId: string; paymentIntentId: string }): SubscriptionRenewalChargeResult;
|
|
31
|
+
finalizeKey(key: string): string;
|
|
32
|
+
executionIndeterminate(): Error;
|
|
33
|
+
defaultNow(): string;
|
|
34
|
+
mandateBlockReason: string;
|
|
35
|
+
invalidTotalsReason: string;
|
|
36
|
+
reservationBlockReason: string;
|
|
37
|
+
preflight: RenewalPreflightPolicy<D, CI, IR>;
|
|
38
|
+
replay: RenewalReplayPolicy;
|
|
39
|
+
outcome: RenewalOutcomePolicy;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One hosted cycle: admission and stock precede one prepare/execute/finalize. */
|
|
43
|
+
export async function executeRenewalCycle<D extends HostedRenewalDue, CI, IR extends RenewalIntentResult,
|
|
44
|
+
P extends string, N extends string, I extends PaymentExecutionBaseInput,
|
|
45
|
+
R extends PaymentExecutionBaseResult & { nextActionKind: N | null },
|
|
46
|
+
M extends string | null | undefined, F extends string>(
|
|
47
|
+
deps: RenewalCycleDeps<CI, IR, P, N, I, R, M>, due: D,
|
|
48
|
+
policy: RenewalCyclePolicy<D, CI, IR, P, I, M, F>,
|
|
49
|
+
): Promise<SubscriptionRenewalChargeResult> {
|
|
50
|
+
const deliveryBlock = deps.deliveryAlignmentAdmission ? null
|
|
51
|
+
: (await checkRenewalDeliveryAdmission(deps.deliveryAlignment, due, (deps.now ?? policy.defaultNow)())).blocked;
|
|
52
|
+
if (deliveryBlock) return deliveryBlock;
|
|
53
|
+
const providerValidation = policy.validateProvider(due);
|
|
54
|
+
if (providerValidation !== null) {
|
|
55
|
+
return recordRenewalPreflightBlock(deps, due, providerValidation, policy.preflight);
|
|
56
|
+
}
|
|
57
|
+
const paymentMethodRecurringModel = deps.capability.requiresStoredMandateEvidence
|
|
58
|
+
? await deps.persistence.readMandateRecurringModel(due.providerKind, due.providerMethodRef) : undefined;
|
|
59
|
+
if (!deps.capability.assessMandate({ recurringModel: paymentMethodRecurringModel }).chargeable) {
|
|
60
|
+
return recordRenewalPreflightBlock(deps, due, policy.mandateBlockReason, policy.preflight);
|
|
61
|
+
}
|
|
62
|
+
const snapshots = await deps.persistence.buildCycleSnapshots({
|
|
63
|
+
subscriptionId: due.subscriptionId,
|
|
64
|
+
scheduledAt: due.nextCycleAt,
|
|
65
|
+
});
|
|
66
|
+
const totalGrossMinor = policy.readTotal(snapshots.orderSnapshot);
|
|
67
|
+
if (totalGrossMinor === null) {
|
|
68
|
+
return policy.failed(due, null, snapshots.cycleNumber, null, null, policy.invalidTotalsReason);
|
|
69
|
+
}
|
|
70
|
+
const cycleOrderIdempotencyKey = policy.cycleKey(due);
|
|
71
|
+
const cycleOrder = await deps.persistence.createCycleOrder({
|
|
72
|
+
idempotencyKey: cycleOrderIdempotencyKey,
|
|
73
|
+
subscriptionId: due.subscriptionId,
|
|
74
|
+
cycleNumber: snapshots.cycleNumber,
|
|
75
|
+
scheduledAt: due.nextCycleAt,
|
|
76
|
+
templateSnapshot: snapshots.templateSnapshot,
|
|
77
|
+
pricingSnapshot: snapshots.pricingSnapshot,
|
|
78
|
+
orderSnapshot: snapshots.orderSnapshot,
|
|
79
|
+
});
|
|
80
|
+
const reservationPreflight = await deps.persistence.preflightReservation({
|
|
81
|
+
orderId: cycleOrder.orderUuid,
|
|
82
|
+
now: (deps.now ?? policy.defaultNow)(),
|
|
83
|
+
});
|
|
84
|
+
if (!reservationPreflight.ok) {
|
|
85
|
+
return policy.failed(
|
|
86
|
+
due,
|
|
87
|
+
cycleOrder.cycleId,
|
|
88
|
+
snapshots.cycleNumber,
|
|
89
|
+
cycleOrder.orderUuid,
|
|
90
|
+
null,
|
|
91
|
+
reservationPreflight.reason ?? policy.reservationBlockReason,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const intent = await deps.paymentPort.createIntent(policy.intentInput(due, cycleOrderIdempotencyKey, snapshots, cycleOrder, totalGrossMinor));
|
|
95
|
+
const executionIdempotencyKey = policy.executionKey(
|
|
96
|
+
cycleOrderIdempotencyKey, snapshots.retryAttempt, snapshots.providerAttemptSequence,
|
|
97
|
+
);
|
|
98
|
+
const attemptIdentity = policy.identity(due, executionIdempotencyKey, intent, cycleOrder, totalGrossMinor);
|
|
99
|
+
const providerFlow = policy.providerFlow(deps.capability.unattendedChargeFlow);
|
|
100
|
+
const state = { snapshots, cycleOrder, intent, totalGrossMinor, executionIdempotencyKey,
|
|
101
|
+
attemptIdentity, providerFlow, paymentMethodRecurringModel };
|
|
102
|
+
const preparedAttempt = await prepareRenewalAttempt(deps.paymentPort,
|
|
103
|
+
policy.prepareInput(due, state), policy.isNotChargeable);
|
|
104
|
+
if (!preparedAttempt) {
|
|
105
|
+
return policy.skippedNotChargeable(due, {
|
|
106
|
+
cycleId: cycleOrder.cycleId,
|
|
107
|
+
cycleNumber: snapshots.cycleNumber,
|
|
108
|
+
orderId: cycleOrder.orderUuid,
|
|
109
|
+
paymentIntentId: intent.paymentIntentId,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (preparedAttempt.replayed) {
|
|
113
|
+
return replayRenewalAttempt(
|
|
114
|
+
deps,
|
|
115
|
+
due,
|
|
116
|
+
{
|
|
117
|
+
cycleId: cycleOrder.cycleId,
|
|
118
|
+
cycleNumber: snapshots.cycleNumber,
|
|
119
|
+
orderId: cycleOrder.orderUuid,
|
|
120
|
+
paymentIntentId: intent.paymentIntentId,
|
|
121
|
+
executionIdempotencyKey,
|
|
122
|
+
providerIdempotencyKey: attemptIdentity.providerIdempotencyKey,
|
|
123
|
+
providerRequestFingerprint: attemptIdentity.providerRequestFingerprint,
|
|
124
|
+
providerKind: due.providerKind,
|
|
125
|
+
},
|
|
126
|
+
preparedAttempt.status, policy.replay,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
// DTO construction and dispatch share the indeterminate execution boundary.
|
|
130
|
+
let execution: R;
|
|
131
|
+
try {
|
|
132
|
+
execution = await deps.executionPort.execute(policy.executionInput(due, state, deps));
|
|
133
|
+
} catch {
|
|
134
|
+
throw policy.executionIndeterminate();
|
|
135
|
+
}
|
|
136
|
+
// Finalization is one raw call outside that catch; it cannot authorize redispatch.
|
|
137
|
+
const finalizedAttempt = await deps.paymentPort.finalizeProviderAttempt({
|
|
138
|
+
idempotencyKey: policy.finalizeKey(executionIdempotencyKey),
|
|
139
|
+
paymentIntentId: intent.paymentIntentId,
|
|
140
|
+
paymentAttemptId: preparedAttempt.paymentAttemptId,
|
|
141
|
+
providerIdempotencyKey: attemptIdentity.providerIdempotencyKey,
|
|
142
|
+
providerRequestFingerprint: attemptIdentity.providerRequestFingerprint,
|
|
143
|
+
providerAttemptId: execution.providerAttemptId,
|
|
144
|
+
providerSessionId: execution.providerSessionId,
|
|
145
|
+
attemptStatus: execution.attemptStatus,
|
|
146
|
+
nextActionKind: execution.nextActionKind,
|
|
147
|
+
requestPayload: execution.requestPayload,
|
|
148
|
+
responsePayload: execution.responsePayload,
|
|
149
|
+
});
|
|
150
|
+
if (execution.providerDecline) {
|
|
151
|
+
return declinedRenewalOutcome(deps, due, {
|
|
152
|
+
decline: execution.providerDecline,
|
|
153
|
+
executionIdempotencyKey,
|
|
154
|
+
attemptIdentity,
|
|
155
|
+
intent,
|
|
156
|
+
cycleOrder,
|
|
157
|
+
snapshots,
|
|
158
|
+
finalizedAttempt,
|
|
159
|
+
}, policy.outcome);
|
|
160
|
+
}
|
|
161
|
+
if (execution.attemptStatus === "requires_action") {
|
|
162
|
+
return requiresActionRenewalOutcome(deps, due, {
|
|
163
|
+
executionIdempotencyKey,
|
|
164
|
+
attemptIdentity,
|
|
165
|
+
intent,
|
|
166
|
+
cycleOrder,
|
|
167
|
+
snapshots,
|
|
168
|
+
finalizedAttempt,
|
|
169
|
+
}, policy.outcome);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
subscriptionId: due.subscriptionId,
|
|
173
|
+
outcome: "charged",
|
|
174
|
+
cycleId: cycleOrder.cycleId,
|
|
175
|
+
cycleNumber: snapshots.cycleNumber,
|
|
176
|
+
orderId: cycleOrder.orderUuid,
|
|
177
|
+
paymentIntentId: intent.paymentIntentId,
|
|
178
|
+
attemptStatus: finalizedAttempt.status,
|
|
179
|
+
replayed: cycleOrder.replayed || intent.replayed,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function prepareRenewalAttempt<P extends string, N extends string>(
|
|
184
|
+
paymentPort: RenewalPreparedAttemptPersistencePort<P, N>, input: RenewalPrepareInput<P>,
|
|
185
|
+
isNotChargeable: (error: unknown) => boolean,
|
|
186
|
+
): Promise<RenewalAttemptResult<N> | null> {
|
|
187
|
+
try {
|
|
188
|
+
return await paymentPort.prepareProviderAttempt(input);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (isNotChargeable(error)) return null;
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function checkRenewalDeliveryAdmission(
|
|
196
|
+
client: RenewalDeliveryClient, due: Pick<HostedRenewalDue, "subscriptionId" | "nextCycleAt">, asOf: string,
|
|
197
|
+
): Promise<{ admitted: RenewalAllowedAdmission; blocked: null } | { admitted: null; blocked: SubscriptionRenewalChargeResult }> {
|
|
198
|
+
const admission = await callRenewalDeliveryAdmission(client, {
|
|
199
|
+
subscriptionId: due.subscriptionId,
|
|
200
|
+
scheduledAt: due.nextCycleAt,
|
|
201
|
+
asOf,
|
|
202
|
+
});
|
|
203
|
+
if (admission.allowed) {
|
|
204
|
+
return { admitted: admission as RenewalAllowedAdmission, blocked: null };
|
|
205
|
+
}
|
|
206
|
+
return { admitted: null, blocked: {
|
|
207
|
+
subscriptionId: due.subscriptionId,
|
|
208
|
+
outcome: "skipped",
|
|
209
|
+
cycleId: null,
|
|
210
|
+
cycleNumber: null,
|
|
211
|
+
orderId: null,
|
|
212
|
+
paymentIntentId: null,
|
|
213
|
+
attemptStatus: null,
|
|
214
|
+
replayed: false,
|
|
215
|
+
reason: admission.reason,
|
|
216
|
+
} };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Preserve the original admission call's async adoption before observing its result.
|
|
220
|
+
async function callRenewalDeliveryAdmission(
|
|
221
|
+
client: RenewalDeliveryClient,
|
|
222
|
+
input: Parameters<RenewalDeliveryClient["admitDeliveryAlignment"]>[0],
|
|
223
|
+
): Promise<RenewalDeliveryAdmission> {
|
|
224
|
+
return client.admitDeliveryAlignment(input);
|
|
225
|
+
}
|