@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,810 @@
|
|
|
1
|
+
-- Public platform shipment rail: the two integration archetypes as executable rows, the port
|
|
2
|
+
-- registry, the shipment machine with its nine-value lifecycle, the external-reference ledger,
|
|
3
|
+
-- the routing decision that chooses between archetypes or fails closed, and the fourth emitter --
|
|
4
|
+
-- the one that turns a machine-raised shipment exception into a notification.
|
|
5
|
+
--
|
|
6
|
+
-- WHAT THIS FORWARD SHIPS. The second bundle already has reads, a queue, a customer-requested
|
|
7
|
+
-- mutation and an operator mutation. This gives it the part of a shop that moves a physical thing:
|
|
8
|
+
-- a paid order becomes a shipment, the shipment walks a durable lifecycle, and WHICH walk is legal
|
|
9
|
+
-- depends on the archetype of the integration it was opened against. Two archetypes, and the
|
|
10
|
+
-- difference between them is the whole point of the slice:
|
|
11
|
+
-- * a CARRIER port -- a direct integration with a courier company. It makes its own label, so it
|
|
12
|
+
-- walks packed -> label_pending -> label_created -> handed_over, and evidence about the parcel
|
|
13
|
+
-- arrives because we ASK for it (a poll);
|
|
14
|
+
-- * a FULFILMENT-HOUSE port -- an integration with a house through which both packing and
|
|
15
|
+
-- shipping run, the latter via various couriers. It makes no label of ours at all, so it walks
|
|
16
|
+
-- packed -> handed_over directly, and evidence ARRIVES on its own (a push).
|
|
17
|
+
-- Operationally the carrier is the fallback of the two, and that fallback is PLATFORM semantics:
|
|
18
|
+
-- the routing function below chooses, and the named integrations stay in the overlay.
|
|
19
|
+
--
|
|
20
|
+
-- THE ARCHETYPE TABLE IS A PROJECTION, NOT AN INVENTION. Every cell of the four seeded rows comes
|
|
21
|
+
-- from a declaration the managed runtime already carries. The five operation columns are its
|
|
22
|
+
-- operation-support matrix verbatim. `owns_label_creation` and `owns_delivered_notice` are
|
|
23
|
+
-- PROJECTED from its per-integration capability profiles up to the archetype, which is legal only
|
|
24
|
+
-- because those two facts agree within each role today -- said out loud, because if a future
|
|
25
|
+
-- profile broke that agreement this table is the first thing that has to be argued with. The two
|
|
26
|
+
-- evidence-channel columns are the structural asymmetry measured for this slice: one poll job on
|
|
27
|
+
-- the carrier side against five push receivers on the fulfilment-house side.
|
|
28
|
+
--
|
|
29
|
+
-- WHY THE FOURTH EMITTER LANDS HERE, AND HOW IT DEPARTS. The order rail authorises it in one
|
|
30
|
+
-- sentence: "a capability that owns a notification owns its own enqueue". A shipment exception is
|
|
31
|
+
-- a fulfilment notification and this forward is the fulfilment capability, so the trigger belongs
|
|
32
|
+
-- here and not on the table's owning slice. The managed emitter early-returns unless the hold's
|
|
33
|
+
-- metadata source equals one literal string that names an integration by brand; that string is a
|
|
34
|
+
-- token this kernel may not write, and metadata is caller-supplied and therefore forgeable. The
|
|
35
|
+
-- neutral gate is the platform-observable fact instead: a hold raised for a fulfilment exception
|
|
36
|
+
-- whose creator is NULL -- the control the upstream auto-heal migration itself calls the
|
|
37
|
+
-- non-forgeable one -- suppressed once the order's shipment is already out of our hands.
|
|
38
|
+
--
|
|
39
|
+
-- WHAT THIS FORWARD DELIBERATELY DOES NOT SHIP, enumerated so nobody has to infer the boundary:
|
|
40
|
+
-- * no inventory reservation relation and no reservation consumption. Upstream, creating a
|
|
41
|
+
-- fulfilment order REFUSES unless every line carries a sufficient reservation -- a precondition
|
|
42
|
+
-- of creation, not a consequence of a transition. This kernel authors no order line and no
|
|
43
|
+
-- catalogue, so its create reads the order row and the port registry and nothing else. That
|
|
44
|
+
-- asymmetry is a measured finding of this wave, and the harness reports it rather than hiding
|
|
45
|
+
-- it;
|
|
46
|
+
-- * no reconciliation shape and no stock oracle. Both carry live operational defects upstream;
|
|
47
|
+
-- * no command ledger for an integration. Upstream it is structurally incapable of expressing a
|
|
48
|
+
-- cancel, by design, and its own design note says do not implement;
|
|
49
|
+
-- * no label bytes, no tracking transport, no webhook receiver and no signature scheme. THE
|
|
50
|
+
-- KERNEL OWNS THE ARCHETYPE'S EVIDENCE CONTRACT; IT NEVER OWNS AN HTTP RECEIVER. Recording that
|
|
51
|
+
-- evidence arrived, on a channel the archetype accepts, is the whole of what is portable;
|
|
52
|
+
-- * no status LABEL. The durable lifecycle is the CHECK below and nothing else. The customer-facing
|
|
53
|
+
-- status map is one authored file upstream with one generated projection, and this forward
|
|
54
|
+
-- writes zero lines in either;
|
|
55
|
+
-- * no closed set of integration keys. A port is registered under an OPAQUE key and carries an
|
|
56
|
+
-- archetype; which named integrations exist is a fact about a deployment, not about a platform;
|
|
57
|
+
-- * no `SECURITY DEFINER`, no `GRANT`/`REVOKE`, no row-level security. This kernel creates zero
|
|
58
|
+
-- roles. Said out loud rather than assumed: ON THIS KERNEL THE FUNCTIONS BELOW ARE NOT A
|
|
59
|
+
-- SECURITY BOUNDARY. `p_actor_id` and `p_port_key` are arguments supplied by a trusted caller;
|
|
60
|
+
-- the host application is what authenticates. `SET search_path` is kept, because that one is a
|
|
61
|
+
-- real injection-hardening property that does not depend on a role model.
|
|
62
|
+
--
|
|
63
|
+
-- AUTHORED, NOT COPIED. Written for this migration against the composed observable behaviour of the
|
|
64
|
+
-- managed shipment functions read end to end -- create, label recorded, handed over, tracking event,
|
|
65
|
+
-- exception recorded, cancel -- plus the two relations they move and the emitter they fire. Named
|
|
66
|
+
-- departures, in full: the names lose their historical prefix so one family token classifies every
|
|
67
|
+
-- refusal; the lifecycle CHECK is NAMED where the upstream one is an inline unnamed column
|
|
68
|
+
-- constraint, because this kernel's convention is that every constraint has a name; the archetype
|
|
69
|
+
-- is DENORMALISED onto the shipment, because a registry edit must not retroactively change which
|
|
70
|
+
-- transition a shipment may take; the external-reference ledger is keyed on the SHIPMENT and names
|
|
71
|
+
-- a reference by its ROLE, where the upstream table is keyed on the order and carries a column
|
|
72
|
+
-- whose values name integrations by brand; that ledger is APPEND-ONLY and the machine reads the
|
|
73
|
+
-- most recent row per role, where upstream keeps history by flipping an `active` flag -- same
|
|
74
|
+
-- property, no mutable column, and no privilege question at all on a kernel with no roles; there is
|
|
75
|
+
-- ONE idempotency ledger and its fingerprint is compared field by field rather than hashed; the
|
|
76
|
+
-- label stage is ARCHETYPE-GATED, which moves the one derived boolean that already discriminates
|
|
77
|
+
-- the two archetypes upstream into an executable refusal; the cancel refusal reads the shipment's
|
|
78
|
+
-- OWN dispatch reference instead of an integration's dispatch table, and is a local state change
|
|
79
|
+
-- with no round trip, because a bidirectional cancel is blocked upstream by design; the routing
|
|
80
|
+
-- selection is read from the order row's own metadata, exactly as the upstream router's reader
|
|
81
|
+
-- does, and the fourth outcome -- a selection that cannot be read is RETRYABLE and never a
|
|
82
|
+
-- fallback -- is reachable through a declared marker because a well-formed input cannot express a
|
|
83
|
+
-- failed read; the emitted payload carries identity and instant and nothing else; and `CREATE` is
|
|
84
|
+
-- unconditional, because a manifest-ordered forward runs exactly once against a known prefix and
|
|
85
|
+
-- `IF NOT EXISTS` there would hide the drift the migration ledger exists to catch.
|
|
86
|
+
--
|
|
87
|
+
-- This forward ALTERS nothing. Every relation below is new, so the two rules the order rail met at
|
|
88
|
+
-- review -- a constraint added to a populated table needs `NOT VALID`, and a foreign key added to
|
|
89
|
+
-- one needs a name -- have no statement here to apply to.
|
|
90
|
+
|
|
91
|
+
-- The archetype, as rows. Four of them, and the machine below branches on the columns rather than
|
|
92
|
+
-- on the key: a port is a carrier because its archetype owns label creation, not because of its
|
|
93
|
+
-- name. The two boolean groups answer different questions -- which operation slots an adapter of
|
|
94
|
+
-- this archetype fills, and which way evidence travels -- and the harness falsifies each of them on
|
|
95
|
+
-- the archetype that does not have it.
|
|
96
|
+
CREATE TABLE public.fulfillment_provider_archetypes (
|
|
97
|
+
provider_type text PRIMARY KEY,
|
|
98
|
+
supports_build_outbound boolean NOT NULL,
|
|
99
|
+
supports_dispatch boolean NOT NULL,
|
|
100
|
+
supports_reconcile boolean NOT NULL,
|
|
101
|
+
supports_parse_webhook boolean NOT NULL,
|
|
102
|
+
supports_sync_stock boolean NOT NULL,
|
|
103
|
+
owns_label_creation boolean NOT NULL,
|
|
104
|
+
owns_delivered_notice boolean NOT NULL,
|
|
105
|
+
accepts_poll_evidence boolean NOT NULL,
|
|
106
|
+
accepts_push_evidence boolean NOT NULL,
|
|
107
|
+
CONSTRAINT fulfillment_provider_archetypes_provider_type_check CHECK (
|
|
108
|
+
provider_type IN ('3pl', 'direct-carrier', 'manual', 'simulator')
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
INSERT INTO public.fulfillment_provider_archetypes (
|
|
113
|
+
provider_type, supports_build_outbound, supports_dispatch, supports_reconcile,
|
|
114
|
+
supports_parse_webhook, supports_sync_stock, owns_label_creation, owns_delivered_notice,
|
|
115
|
+
accepts_poll_evidence, accepts_push_evidence
|
|
116
|
+
) VALUES
|
|
117
|
+
('3pl', true, true, true, true, true, false, true, true, true),
|
|
118
|
+
('direct-carrier', true, true, false, false, false, true, false, true, false),
|
|
119
|
+
('simulator', false, true, false, false, false, true, false, true, false),
|
|
120
|
+
('manual', false, false, false, false, false, false, false, false, false);
|
|
121
|
+
|
|
122
|
+
-- The registry. `port_key` is opaque on purpose: the kernel never learns which integration a key
|
|
123
|
+
-- names, only which archetype it behaves like. `fail_closed` is the one policy bit the routing
|
|
124
|
+
-- decision needs, and an unroutable port must SAY why -- a port that is off without a reason
|
|
125
|
+
-- produces a refusal nobody can act on.
|
|
126
|
+
CREATE TABLE public.fulfillment_ports (
|
|
127
|
+
port_key text PRIMARY KEY,
|
|
128
|
+
provider_type text NOT NULL REFERENCES public.fulfillment_provider_archetypes(provider_type),
|
|
129
|
+
routable boolean NOT NULL DEFAULT true,
|
|
130
|
+
fail_closed boolean NOT NULL DEFAULT false,
|
|
131
|
+
unavailable_reason text,
|
|
132
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
133
|
+
CONSTRAINT fulfillment_ports_port_key_nonempty_check CHECK (btrim(port_key) <> ''),
|
|
134
|
+
CONSTRAINT fulfillment_ports_unavailable_reason_check CHECK (
|
|
135
|
+
routable OR (unavailable_reason IS NOT NULL AND btrim(unavailable_reason) <> '')
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
-- The machine. `provider_type` is copied from the port at creation and never re-read: a shipment's
|
|
140
|
+
-- archetype is a fact about the shipment, and a machine that re-read it would answer the same
|
|
141
|
+
-- transition differently after an unrelated registry edit.
|
|
142
|
+
CREATE TABLE public.fulfillment_shipments (
|
|
143
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
144
|
+
order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
|
|
145
|
+
port_key text NOT NULL REFERENCES public.fulfillment_ports(port_key) ON DELETE RESTRICT,
|
|
146
|
+
provider_type text NOT NULL REFERENCES public.fulfillment_provider_archetypes(provider_type),
|
|
147
|
+
status text NOT NULL DEFAULT 'created',
|
|
148
|
+
create_idempotency_key text NOT NULL,
|
|
149
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
150
|
+
packed_at timestamptz,
|
|
151
|
+
handed_over_at timestamptz,
|
|
152
|
+
delivered_at timestamptz,
|
|
153
|
+
cancelled_at timestamptz,
|
|
154
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
155
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
156
|
+
CONSTRAINT fulfillment_shipments_status_check CHECK (status IN (
|
|
157
|
+
'created', 'packed', 'label_pending', 'label_created', 'handed_over',
|
|
158
|
+
'in_transit', 'delivered', 'exception', 'cancelled'
|
|
159
|
+
)),
|
|
160
|
+
CONSTRAINT fulfillment_shipments_create_idempotency_key_key UNIQUE (create_idempotency_key),
|
|
161
|
+
CONSTRAINT fulfillment_shipments_order_id_key UNIQUE (order_id)
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE INDEX idx_fulfillment_shipments_order
|
|
165
|
+
ON public.fulfillment_shipments (order_id, created_at DESC);
|
|
166
|
+
|
|
167
|
+
-- The neutral external-reference ledger. Upstream this is keyed on the order and carries a column
|
|
168
|
+
-- whose values name integrations; here the shipment already carries the archetype, so the only
|
|
169
|
+
-- thing left to say about a reference is what ROLE it plays. Append-only: nothing below updates or
|
|
170
|
+
-- deletes a row here, and a newer reference for one role supersedes an older one by recency rather
|
|
171
|
+
-- than by flipping a flag, which keeps the audit history the upstream flag exists to keep.
|
|
172
|
+
CREATE TABLE public.shipment_external_refs (
|
|
173
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
174
|
+
shipment_id uuid NOT NULL REFERENCES public.fulfillment_shipments(id) ON DELETE CASCADE,
|
|
175
|
+
ref_role text NOT NULL,
|
|
176
|
+
external_id text NOT NULL,
|
|
177
|
+
recorded_at timestamptz NOT NULL DEFAULT now(),
|
|
178
|
+
CONSTRAINT shipment_external_refs_ref_role_check CHECK (
|
|
179
|
+
ref_role IN ('dispatch', 'tracking', 'label')
|
|
180
|
+
),
|
|
181
|
+
CONSTRAINT shipment_external_refs_external_id_nonempty_check CHECK (btrim(external_id) <> ''),
|
|
182
|
+
CONSTRAINT shipment_external_refs_shipment_role_id_key UNIQUE (shipment_id, ref_role, external_id)
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
CREATE INDEX idx_shipment_external_refs_shipment
|
|
186
|
+
ON public.shipment_external_refs (shipment_id, ref_role, recorded_at DESC);
|
|
187
|
+
|
|
188
|
+
-- The audit ledger AND the idempotency ledger, in one table -- the device the order rail settled
|
|
189
|
+
-- on, inherited unchanged. A second generic key table would bring back the defect it has upstream:
|
|
190
|
+
-- its request fingerprint hashes the actor, so for a machine actor deduplication silently stops.
|
|
191
|
+
CREATE TABLE public.fulfillment_shipment_operations (
|
|
192
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
193
|
+
shipment_id uuid REFERENCES public.fulfillment_shipments(id) ON DELETE CASCADE,
|
|
194
|
+
order_id uuid NOT NULL REFERENCES public.commerce_orders(id) ON DELETE CASCADE,
|
|
195
|
+
operation_type text NOT NULL,
|
|
196
|
+
source text NOT NULL DEFAULT 'platform.fulfillment.v1',
|
|
197
|
+
idempotency_key text NOT NULL,
|
|
198
|
+
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
199
|
+
occurred_at timestamptz NOT NULL DEFAULT now(),
|
|
200
|
+
CONSTRAINT fulfillment_shipment_operations_operation_type_check CHECK (operation_type IN (
|
|
201
|
+
'shipment_created', 'shipment_advanced', 'evidence_recorded',
|
|
202
|
+
'shipment_cancelled', 'shipment_exception_raised', 'order_routed'
|
|
203
|
+
)),
|
|
204
|
+
CONSTRAINT fulfillment_shipment_operations_idempotency_key_nonempty_check
|
|
205
|
+
CHECK (btrim(idempotency_key) <> ''),
|
|
206
|
+
CONSTRAINT fulfillment_shipment_operations_idempotency_key_key UNIQUE (idempotency_key)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
CREATE INDEX idx_fulfillment_shipment_operations_order
|
|
210
|
+
ON public.fulfillment_shipment_operations (order_id, occurred_at DESC);
|
|
211
|
+
|
|
212
|
+
-- One response shape for every entry point, so an applied transition and a replayed one are
|
|
213
|
+
-- answered by the same builder and cannot drift apart.
|
|
214
|
+
CREATE FUNCTION public.fulfillment_shipment_response(
|
|
215
|
+
p_shipment public.fulfillment_shipments,
|
|
216
|
+
p_outcome text,
|
|
217
|
+
p_replayed boolean
|
|
218
|
+
)
|
|
219
|
+
RETURNS jsonb
|
|
220
|
+
LANGUAGE sql
|
|
221
|
+
IMMUTABLE
|
|
222
|
+
SET search_path = public, pg_catalog
|
|
223
|
+
AS $$
|
|
224
|
+
SELECT jsonb_build_object(
|
|
225
|
+
'contractVersion', 'platform.fulfillment.shipment.v1',
|
|
226
|
+
'shipment', CASE WHEN p_shipment.id IS NULL THEN NULL ELSE jsonb_build_object(
|
|
227
|
+
'id', p_shipment.id, 'orderId', p_shipment.order_id, 'status', p_shipment.status,
|
|
228
|
+
'providerType', p_shipment.provider_type) END,
|
|
229
|
+
'outcome', p_outcome,
|
|
230
|
+
'replayed', p_replayed);
|
|
231
|
+
$$;
|
|
232
|
+
|
|
233
|
+
-- The opener the two entry points share. It is deliberately NOT an entry point of its own: both
|
|
234
|
+
-- callers below have already decided that they may open a shipment, and the only thing that could
|
|
235
|
+
-- still refuse here is the port.
|
|
236
|
+
CREATE FUNCTION public.fulfillment_open_shipment(
|
|
237
|
+
p_idempotency_key text,
|
|
238
|
+
p_order_id uuid,
|
|
239
|
+
p_port_key text,
|
|
240
|
+
p_metadata jsonb,
|
|
241
|
+
p_requested_at timestamptz
|
|
242
|
+
)
|
|
243
|
+
RETURNS public.fulfillment_shipments
|
|
244
|
+
LANGUAGE plpgsql
|
|
245
|
+
SET search_path = public, pg_catalog
|
|
246
|
+
AS $$
|
|
247
|
+
DECLARE
|
|
248
|
+
v_port public.fulfillment_ports%ROWTYPE;
|
|
249
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
250
|
+
BEGIN
|
|
251
|
+
SELECT * INTO v_port FROM public.fulfillment_ports WHERE port_key = p_port_key;
|
|
252
|
+
IF NOT FOUND THEN
|
|
253
|
+
RAISE EXCEPTION 'fulfillment_port_not_registered' USING ERRCODE = '22023';
|
|
254
|
+
END IF;
|
|
255
|
+
IF NOT v_port.routable THEN
|
|
256
|
+
RAISE EXCEPTION 'fulfillment_port_not_routable' USING ERRCODE = '22023';
|
|
257
|
+
END IF;
|
|
258
|
+
|
|
259
|
+
INSERT INTO public.fulfillment_shipments
|
|
260
|
+
(order_id, port_key, provider_type, create_idempotency_key, metadata, created_at, updated_at)
|
|
261
|
+
VALUES (p_order_id, v_port.port_key, v_port.provider_type, p_idempotency_key,
|
|
262
|
+
COALESCE(p_metadata, '{}'::jsonb), p_requested_at, p_requested_at)
|
|
263
|
+
RETURNING * INTO v_shipment;
|
|
264
|
+
|
|
265
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
266
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
267
|
+
VALUES (v_shipment.id, p_order_id, 'shipment_created', p_idempotency_key || ':operation',
|
|
268
|
+
jsonb_build_object('portKey', v_port.port_key, 'providerType', v_port.provider_type),
|
|
269
|
+
p_requested_at);
|
|
270
|
+
|
|
271
|
+
RETURN v_shipment;
|
|
272
|
+
END;
|
|
273
|
+
$$;
|
|
274
|
+
|
|
275
|
+
-- Open a shipment for an order somebody has paid for.
|
|
276
|
+
--
|
|
277
|
+
-- Upstream the equivalent refuses unless four other capabilities have already written: a succeeded
|
|
278
|
+
-- payment intent, a shipping address, catalogue-resolvable order lines, and a sufficient inventory
|
|
279
|
+
-- reservation per line. Three of those are relations this kernel does not author and the fourth is
|
|
280
|
+
-- a capability this slice was told to leave alone; on this kernel the order row IS the record that
|
|
281
|
+
-- the money moved, so `status IN ('paid','fulfillment_pending')` is the whole of the gate. If a
|
|
282
|
+
-- payment or inventory relation is ever authored here, this function is the one that has to change
|
|
283
|
+
-- first, and it should change by ADDING a check rather than by moving the decision elsewhere.
|
|
284
|
+
CREATE FUNCTION public.fulfillment_create_shipment(
|
|
285
|
+
p_idempotency_key text,
|
|
286
|
+
p_order_id uuid,
|
|
287
|
+
p_port_key text,
|
|
288
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
289
|
+
p_requested_at timestamptz DEFAULT now()
|
|
290
|
+
)
|
|
291
|
+
RETURNS jsonb
|
|
292
|
+
LANGUAGE plpgsql
|
|
293
|
+
SET search_path = public, pg_catalog
|
|
294
|
+
AS $$
|
|
295
|
+
DECLARE
|
|
296
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
297
|
+
v_order public.commerce_orders%ROWTYPE;
|
|
298
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
299
|
+
BEGIN
|
|
300
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
301
|
+
OR p_order_id IS NULL OR p_port_key IS NULL
|
|
302
|
+
THEN
|
|
303
|
+
RAISE EXCEPTION 'fulfillment_create_shipment_invalid_input' USING ERRCODE = '22023';
|
|
304
|
+
END IF;
|
|
305
|
+
|
|
306
|
+
SELECT * INTO v_existing
|
|
307
|
+
FROM public.fulfillment_shipment_operations
|
|
308
|
+
WHERE idempotency_key = p_idempotency_key || ':operation';
|
|
309
|
+
|
|
310
|
+
IF FOUND THEN
|
|
311
|
+
IF v_existing.operation_type <> 'shipment_created'
|
|
312
|
+
OR v_existing.order_id IS DISTINCT FROM p_order_id
|
|
313
|
+
OR v_existing.payload->>'portKey' IS DISTINCT FROM p_port_key
|
|
314
|
+
THEN
|
|
315
|
+
RAISE EXCEPTION 'fulfillment_create_shipment_idempotency_conflict' USING ERRCODE = '23505';
|
|
316
|
+
END IF;
|
|
317
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = v_existing.shipment_id;
|
|
318
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'created', true);
|
|
319
|
+
END IF;
|
|
320
|
+
|
|
321
|
+
SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
|
|
322
|
+
IF NOT FOUND THEN
|
|
323
|
+
RAISE EXCEPTION 'fulfillment_create_shipment_order_not_found' USING ERRCODE = '22023';
|
|
324
|
+
END IF;
|
|
325
|
+
IF v_order.status NOT IN ('paid', 'fulfillment_pending') THEN
|
|
326
|
+
RAISE EXCEPTION 'fulfillment_create_shipment_order_not_fulfillable' USING ERRCODE = '22023';
|
|
327
|
+
END IF;
|
|
328
|
+
|
|
329
|
+
v_shipment := public.fulfillment_open_shipment(
|
|
330
|
+
p_idempotency_key, p_order_id, p_port_key, p_metadata, p_requested_at);
|
|
331
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'created', false);
|
|
332
|
+
END;
|
|
333
|
+
$$;
|
|
334
|
+
|
|
335
|
+
-- Route a paid order to an archetype, or refuse in the one way that is not a fallback.
|
|
336
|
+
--
|
|
337
|
+
-- Four outcomes, and the asymmetry between the middle two is the rule worth proving: a selection
|
|
338
|
+
-- that names a port which is registered, unroutable AND fail-closed does NOT silently degrade to
|
|
339
|
+
-- the other archetype -- it snoozes with the reason the registry names. A selection naming a port
|
|
340
|
+
-- this deployment simply does not have is not a policy statement, so it falls back. And a selection
|
|
341
|
+
-- that cannot be READ is retryable and never anything else, because a transient read failure that
|
|
342
|
+
-- fell back would strand the order on the wrong archetype -- the one outcome a well-formed input
|
|
343
|
+
-- cannot express, so it is reachable here through a marker the caller writes on purpose.
|
|
344
|
+
CREATE FUNCTION public.fulfillment_route_order(
|
|
345
|
+
p_idempotency_key text,
|
|
346
|
+
p_order_id uuid,
|
|
347
|
+
p_fallback_port_key text,
|
|
348
|
+
p_requested_at timestamptz DEFAULT now()
|
|
349
|
+
)
|
|
350
|
+
RETURNS jsonb
|
|
351
|
+
LANGUAGE plpgsql
|
|
352
|
+
SET search_path = public, pg_catalog
|
|
353
|
+
AS $$
|
|
354
|
+
DECLARE
|
|
355
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
356
|
+
v_order public.commerce_orders%ROWTYPE;
|
|
357
|
+
v_selection jsonb;
|
|
358
|
+
v_selected text;
|
|
359
|
+
v_port public.fulfillment_ports%ROWTYPE;
|
|
360
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
361
|
+
v_outcome text;
|
|
362
|
+
v_target text;
|
|
363
|
+
BEGIN
|
|
364
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
365
|
+
OR p_order_id IS NULL OR p_fallback_port_key IS NULL
|
|
366
|
+
THEN
|
|
367
|
+
RAISE EXCEPTION 'fulfillment_route_order_invalid_input' USING ERRCODE = '22023';
|
|
368
|
+
END IF;
|
|
369
|
+
|
|
370
|
+
SELECT * INTO v_existing
|
|
371
|
+
FROM public.fulfillment_shipment_operations WHERE idempotency_key = p_idempotency_key;
|
|
372
|
+
IF FOUND THEN
|
|
373
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = v_existing.shipment_id;
|
|
374
|
+
RETURN public.fulfillment_shipment_response(
|
|
375
|
+
v_shipment, v_existing.payload->>'outcome', true);
|
|
376
|
+
END IF;
|
|
377
|
+
|
|
378
|
+
SELECT * INTO v_order FROM public.commerce_orders WHERE id = p_order_id FOR UPDATE;
|
|
379
|
+
IF NOT FOUND THEN
|
|
380
|
+
RAISE EXCEPTION 'fulfillment_route_order_not_found' USING ERRCODE = '22023';
|
|
381
|
+
END IF;
|
|
382
|
+
IF v_order.status NOT IN ('paid', 'fulfillment_pending') THEN
|
|
383
|
+
RAISE EXCEPTION 'fulfillment_route_order_not_fulfillable' USING ERRCODE = '22023';
|
|
384
|
+
END IF;
|
|
385
|
+
|
|
386
|
+
v_selection := v_order.metadata->'fulfillmentSelection';
|
|
387
|
+
IF v_selection->>'resolution' = 'unreadable' THEN
|
|
388
|
+
-- 40001, so a caller that already retries a serialization failure retries this too.
|
|
389
|
+
RAISE EXCEPTION 'fulfillment_route_order_selection_unreadable' USING ERRCODE = '40001';
|
|
390
|
+
END IF;
|
|
391
|
+
v_selected := v_selection->>'portKey';
|
|
392
|
+
|
|
393
|
+
IF v_selected IS NOT NULL THEN
|
|
394
|
+
SELECT * INTO v_port FROM public.fulfillment_ports WHERE port_key = v_selected;
|
|
395
|
+
END IF;
|
|
396
|
+
|
|
397
|
+
-- `v_port.port_key IS NOT NULL` rather than `FOUND`, because `FOUND` also carries the result of
|
|
398
|
+
-- the order read above and would answer for a lookup that never ran.
|
|
399
|
+
IF v_port.port_key IS NOT NULL AND v_port.routable THEN
|
|
400
|
+
v_outcome := 'routed';
|
|
401
|
+
v_target := v_port.port_key;
|
|
402
|
+
ELSIF v_port.port_key IS NOT NULL AND v_port.fail_closed THEN
|
|
403
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
404
|
+
(order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
405
|
+
VALUES (p_order_id, 'order_routed', p_idempotency_key,
|
|
406
|
+
jsonb_build_object('outcome', 'snoozed', 'reason', v_port.unavailable_reason),
|
|
407
|
+
p_requested_at);
|
|
408
|
+
RETURN jsonb_build_object('contractVersion', 'platform.fulfillment.shipment.v1',
|
|
409
|
+
'shipment', NULL, 'outcome', 'snoozed', 'reason', v_port.unavailable_reason,
|
|
410
|
+
'replayed', false);
|
|
411
|
+
ELSE
|
|
412
|
+
v_outcome := 'fallback';
|
|
413
|
+
v_target := p_fallback_port_key;
|
|
414
|
+
END IF;
|
|
415
|
+
|
|
416
|
+
v_shipment := public.fulfillment_open_shipment(
|
|
417
|
+
p_idempotency_key, p_order_id, v_target, jsonb_build_object('routing', v_outcome),
|
|
418
|
+
p_requested_at);
|
|
419
|
+
|
|
420
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
421
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
422
|
+
VALUES (v_shipment.id, p_order_id, 'order_routed', p_idempotency_key,
|
|
423
|
+
jsonb_build_object('outcome', v_outcome, 'portKey', v_target), p_requested_at);
|
|
424
|
+
|
|
425
|
+
RETURN public.fulfillment_shipment_response(v_shipment, v_outcome, false);
|
|
426
|
+
END;
|
|
427
|
+
$$;
|
|
428
|
+
|
|
429
|
+
-- Move a shipment one step, and refuse the steps its archetype has not earned.
|
|
430
|
+
--
|
|
431
|
+
-- Two refusals live here and they are different in kind. `invalid_transition` is about the
|
|
432
|
+
-- lifecycle: no shipment goes from `created` straight to `delivered`, whatever it is wired to.
|
|
433
|
+
-- `archetype_forbids_transition` is about the integration model: only an archetype that makes its
|
|
434
|
+
-- own label may enter the label stages, and only an archetype that does NOT may hand over straight
|
|
435
|
+
-- from `packed`. That second refusal is the one line of derived logic the managed runtime already
|
|
436
|
+
-- computes for its console -- auto-dispatch AND NOT label-creation -- moved from a boolean nobody
|
|
437
|
+
-- can execute into a transition the machine actually refuses.
|
|
438
|
+
CREATE FUNCTION public.fulfillment_advance_shipment(
|
|
439
|
+
p_idempotency_key text,
|
|
440
|
+
p_shipment_id uuid,
|
|
441
|
+
p_to_status text,
|
|
442
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
443
|
+
p_requested_at timestamptz DEFAULT now()
|
|
444
|
+
)
|
|
445
|
+
RETURNS jsonb
|
|
446
|
+
LANGUAGE plpgsql
|
|
447
|
+
SET search_path = public, pg_catalog
|
|
448
|
+
AS $$
|
|
449
|
+
DECLARE
|
|
450
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
451
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
452
|
+
v_archetype public.fulfillment_provider_archetypes%ROWTYPE;
|
|
453
|
+
v_legal boolean;
|
|
454
|
+
BEGIN
|
|
455
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
456
|
+
OR p_shipment_id IS NULL OR p_to_status IS NULL
|
|
457
|
+
THEN
|
|
458
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_invalid_input' USING ERRCODE = '22023';
|
|
459
|
+
END IF;
|
|
460
|
+
|
|
461
|
+
SELECT * INTO v_existing
|
|
462
|
+
FROM public.fulfillment_shipment_operations WHERE idempotency_key = p_idempotency_key;
|
|
463
|
+
IF FOUND THEN
|
|
464
|
+
IF v_existing.operation_type <> 'shipment_advanced'
|
|
465
|
+
OR v_existing.shipment_id IS DISTINCT FROM p_shipment_id
|
|
466
|
+
OR v_existing.payload->>'toStatus' IS DISTINCT FROM p_to_status
|
|
467
|
+
THEN
|
|
468
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_idempotency_conflict' USING ERRCODE = '23505';
|
|
469
|
+
END IF;
|
|
470
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id;
|
|
471
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'advanced', true);
|
|
472
|
+
END IF;
|
|
473
|
+
|
|
474
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id FOR UPDATE;
|
|
475
|
+
IF NOT FOUND THEN
|
|
476
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_not_found' USING ERRCODE = '22023';
|
|
477
|
+
END IF;
|
|
478
|
+
|
|
479
|
+
SELECT * INTO v_archetype FROM public.fulfillment_provider_archetypes
|
|
480
|
+
WHERE provider_type = v_shipment.provider_type;
|
|
481
|
+
|
|
482
|
+
v_legal := (v_shipment.status, p_to_status) IN (
|
|
483
|
+
('created', 'packed'),
|
|
484
|
+
('packed', 'label_pending'),
|
|
485
|
+
('label_pending', 'label_created'),
|
|
486
|
+
('label_created', 'handed_over'),
|
|
487
|
+
('packed', 'handed_over'),
|
|
488
|
+
('handed_over', 'in_transit'),
|
|
489
|
+
('handed_over', 'delivered'),
|
|
490
|
+
('in_transit', 'delivered'));
|
|
491
|
+
IF NOT v_legal THEN
|
|
492
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_invalid_transition' USING ERRCODE = '22023';
|
|
493
|
+
END IF;
|
|
494
|
+
|
|
495
|
+
-- Two refusals, not one, because they are different facts and a caller has to tell them apart: a
|
|
496
|
+
-- house was asked for a label it never makes, and a carrier was asked to hand over a parcel it has
|
|
497
|
+
-- not labelled yet.
|
|
498
|
+
IF p_to_status IN ('label_pending', 'label_created') AND NOT v_archetype.owns_label_creation THEN
|
|
499
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_archetype_forbids_label' USING ERRCODE = '22023';
|
|
500
|
+
END IF;
|
|
501
|
+
IF v_shipment.status = 'packed' AND p_to_status = 'handed_over'
|
|
502
|
+
AND v_archetype.owns_label_creation
|
|
503
|
+
THEN
|
|
504
|
+
RAISE EXCEPTION 'fulfillment_advance_shipment_archetype_forbids_handover' USING ERRCODE = '22023';
|
|
505
|
+
END IF;
|
|
506
|
+
|
|
507
|
+
UPDATE public.fulfillment_shipments
|
|
508
|
+
SET status = p_to_status,
|
|
509
|
+
updated_at = p_requested_at,
|
|
510
|
+
packed_at = CASE WHEN p_to_status = 'packed' THEN p_requested_at ELSE packed_at END,
|
|
511
|
+
handed_over_at = CASE WHEN p_to_status = 'handed_over' THEN p_requested_at
|
|
512
|
+
ELSE handed_over_at END,
|
|
513
|
+
delivered_at = CASE WHEN p_to_status = 'delivered' THEN p_requested_at
|
|
514
|
+
ELSE delivered_at END,
|
|
515
|
+
metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
|
|
516
|
+
WHERE id = p_shipment_id
|
|
517
|
+
RETURNING * INTO v_shipment;
|
|
518
|
+
|
|
519
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
520
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
521
|
+
VALUES (p_shipment_id, v_shipment.order_id, 'shipment_advanced', p_idempotency_key,
|
|
522
|
+
jsonb_build_object('toStatus', p_to_status), p_requested_at);
|
|
523
|
+
|
|
524
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'advanced', false);
|
|
525
|
+
END;
|
|
526
|
+
$$;
|
|
527
|
+
|
|
528
|
+
-- Record that evidence about a shipment arrived, on a channel its archetype accepts.
|
|
529
|
+
--
|
|
530
|
+
-- THE BOUNDARY THIS FUNCTION DRAWS IS THE POINT OF THE WHOLE SLICE. It takes evidence that has
|
|
531
|
+
-- already been normalized -- a channel, a role, an identifier -- and it does not know, and must
|
|
532
|
+
-- never know, how that evidence got here. No signature, no payload shape, no receiver. What is
|
|
533
|
+
-- portable is that an archetype which only ever ASKS cannot be told, and one that is TOLD needs no
|
|
534
|
+
-- schedule; everything else is an adapter's problem and stays in the overlay.
|
|
535
|
+
CREATE FUNCTION public.fulfillment_record_evidence(
|
|
536
|
+
p_idempotency_key text,
|
|
537
|
+
p_shipment_id uuid,
|
|
538
|
+
p_channel text,
|
|
539
|
+
p_ref_role text,
|
|
540
|
+
p_external_id text,
|
|
541
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
542
|
+
p_requested_at timestamptz DEFAULT now()
|
|
543
|
+
)
|
|
544
|
+
RETURNS jsonb
|
|
545
|
+
LANGUAGE plpgsql
|
|
546
|
+
SET search_path = public, pg_catalog
|
|
547
|
+
AS $$
|
|
548
|
+
DECLARE
|
|
549
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
550
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
551
|
+
v_archetype public.fulfillment_provider_archetypes%ROWTYPE;
|
|
552
|
+
BEGIN
|
|
553
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
554
|
+
OR p_shipment_id IS NULL OR p_channel IS NULL OR p_channel NOT IN ('poll', 'push')
|
|
555
|
+
OR p_ref_role IS NULL OR p_ref_role NOT IN ('dispatch', 'tracking', 'label')
|
|
556
|
+
OR p_external_id IS NULL OR btrim(p_external_id) = ''
|
|
557
|
+
THEN
|
|
558
|
+
RAISE EXCEPTION 'fulfillment_record_evidence_invalid_input' USING ERRCODE = '22023';
|
|
559
|
+
END IF;
|
|
560
|
+
|
|
561
|
+
SELECT * INTO v_existing
|
|
562
|
+
FROM public.fulfillment_shipment_operations WHERE idempotency_key = p_idempotency_key;
|
|
563
|
+
IF FOUND THEN
|
|
564
|
+
IF v_existing.operation_type <> 'evidence_recorded'
|
|
565
|
+
OR v_existing.shipment_id IS DISTINCT FROM p_shipment_id
|
|
566
|
+
OR v_existing.payload->>'externalId' IS DISTINCT FROM p_external_id
|
|
567
|
+
THEN
|
|
568
|
+
RAISE EXCEPTION 'fulfillment_record_evidence_idempotency_conflict' USING ERRCODE = '23505';
|
|
569
|
+
END IF;
|
|
570
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id;
|
|
571
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'evidence_recorded', true);
|
|
572
|
+
END IF;
|
|
573
|
+
|
|
574
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id FOR UPDATE;
|
|
575
|
+
IF NOT FOUND THEN
|
|
576
|
+
RAISE EXCEPTION 'fulfillment_record_evidence_shipment_not_found' USING ERRCODE = '22023';
|
|
577
|
+
END IF;
|
|
578
|
+
IF v_shipment.status IN ('cancelled', 'delivered') THEN
|
|
579
|
+
RAISE EXCEPTION 'fulfillment_record_evidence_shipment_closed' USING ERRCODE = '22023';
|
|
580
|
+
END IF;
|
|
581
|
+
|
|
582
|
+
SELECT * INTO v_archetype FROM public.fulfillment_provider_archetypes
|
|
583
|
+
WHERE provider_type = v_shipment.provider_type;
|
|
584
|
+
IF (p_channel = 'poll' AND NOT v_archetype.accepts_poll_evidence)
|
|
585
|
+
OR (p_channel = 'push' AND NOT v_archetype.accepts_push_evidence)
|
|
586
|
+
THEN
|
|
587
|
+
RAISE EXCEPTION 'fulfillment_record_evidence_channel_unsupported' USING ERRCODE = '22023';
|
|
588
|
+
END IF;
|
|
589
|
+
|
|
590
|
+
INSERT INTO public.shipment_external_refs (shipment_id, ref_role, external_id, recorded_at)
|
|
591
|
+
VALUES (p_shipment_id, p_ref_role, p_external_id, p_requested_at)
|
|
592
|
+
ON CONFLICT ON CONSTRAINT shipment_external_refs_shipment_role_id_key DO NOTHING;
|
|
593
|
+
|
|
594
|
+
UPDATE public.fulfillment_shipments
|
|
595
|
+
SET updated_at = p_requested_at,
|
|
596
|
+
metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
|
|
597
|
+
WHERE id = p_shipment_id
|
|
598
|
+
RETURNING * INTO v_shipment;
|
|
599
|
+
|
|
600
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
601
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
602
|
+
VALUES (p_shipment_id, v_shipment.order_id, 'evidence_recorded', p_idempotency_key,
|
|
603
|
+
jsonb_build_object('channel', p_channel, 'refRole', p_ref_role,
|
|
604
|
+
'externalId', p_external_id), p_requested_at);
|
|
605
|
+
|
|
606
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'evidence_recorded', false);
|
|
607
|
+
END;
|
|
608
|
+
$$;
|
|
609
|
+
|
|
610
|
+
-- Cancel a shipment locally, and refuse while somebody else still holds it.
|
|
611
|
+
--
|
|
612
|
+
-- Upstream, an outbound cancel toward an integration is declared, implemented and called from
|
|
613
|
+
-- nowhere; every capability profile says it cannot cancel; the command ledger is structurally
|
|
614
|
+
-- unable to express one; and the design note for the missing half reads "do not implement". So the
|
|
615
|
+
-- portable behaviour is exactly this: a LOCAL state change, refused once somebody outside this
|
|
616
|
+
-- system has been committed to. Never a round trip, never a two-way sync.
|
|
617
|
+
--
|
|
618
|
+
-- What counts as committed differs by archetype, and BOTH halves are needed, which is why the
|
|
619
|
+
-- refusal has two arms. A carrier is committed the moment a label of ours exists -- upstream draws
|
|
620
|
+
-- the line there for everyone. A fulfilment house never makes a label of ours at all, so for it the
|
|
621
|
+
-- only evidence that somebody else is holding the parcel is the dispatch reference it handed back.
|
|
622
|
+
-- One arm alone would let one of the two archetypes cancel a parcel already in somebody's van.
|
|
623
|
+
CREATE FUNCTION public.fulfillment_cancel_shipment(
|
|
624
|
+
p_idempotency_key text,
|
|
625
|
+
p_shipment_id uuid,
|
|
626
|
+
p_reason text,
|
|
627
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
628
|
+
p_requested_at timestamptz DEFAULT now()
|
|
629
|
+
)
|
|
630
|
+
RETURNS jsonb
|
|
631
|
+
LANGUAGE plpgsql
|
|
632
|
+
SET search_path = public, pg_catalog
|
|
633
|
+
AS $$
|
|
634
|
+
DECLARE
|
|
635
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
636
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
637
|
+
BEGIN
|
|
638
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
639
|
+
OR p_shipment_id IS NULL OR p_reason IS NULL OR btrim(p_reason) = ''
|
|
640
|
+
THEN
|
|
641
|
+
RAISE EXCEPTION 'fulfillment_cancel_shipment_invalid_input' USING ERRCODE = '22023';
|
|
642
|
+
END IF;
|
|
643
|
+
|
|
644
|
+
SELECT * INTO v_existing
|
|
645
|
+
FROM public.fulfillment_shipment_operations WHERE idempotency_key = p_idempotency_key;
|
|
646
|
+
IF FOUND THEN
|
|
647
|
+
IF v_existing.operation_type <> 'shipment_cancelled'
|
|
648
|
+
OR v_existing.shipment_id IS DISTINCT FROM p_shipment_id
|
|
649
|
+
OR v_existing.payload->>'reason' IS DISTINCT FROM p_reason
|
|
650
|
+
THEN
|
|
651
|
+
RAISE EXCEPTION 'fulfillment_cancel_shipment_idempotency_conflict' USING ERRCODE = '23505';
|
|
652
|
+
END IF;
|
|
653
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id;
|
|
654
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'cancelled', true);
|
|
655
|
+
END IF;
|
|
656
|
+
|
|
657
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id FOR UPDATE;
|
|
658
|
+
IF NOT FOUND THEN
|
|
659
|
+
RAISE EXCEPTION 'fulfillment_cancel_shipment_not_found' USING ERRCODE = '22023';
|
|
660
|
+
END IF;
|
|
661
|
+
IF v_shipment.status = 'cancelled' THEN
|
|
662
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'cancelled', true);
|
|
663
|
+
END IF;
|
|
664
|
+
IF v_shipment.status IN ('label_created', 'handed_over', 'in_transit', 'delivered') THEN
|
|
665
|
+
RAISE EXCEPTION 'fulfillment_cancel_shipment_invalid_status' USING ERRCODE = '22023';
|
|
666
|
+
END IF;
|
|
667
|
+
IF EXISTS (
|
|
668
|
+
SELECT 1 FROM public.shipment_external_refs
|
|
669
|
+
WHERE shipment_id = p_shipment_id AND ref_role = 'dispatch'
|
|
670
|
+
) THEN
|
|
671
|
+
RAISE EXCEPTION 'fulfillment_cancel_shipment_dispatch_outstanding' USING ERRCODE = '22023';
|
|
672
|
+
END IF;
|
|
673
|
+
|
|
674
|
+
UPDATE public.fulfillment_shipments
|
|
675
|
+
SET status = 'cancelled',
|
|
676
|
+
cancelled_at = p_requested_at,
|
|
677
|
+
updated_at = p_requested_at,
|
|
678
|
+
metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
|
|
679
|
+
|| jsonb_build_object('cancellationReason', p_reason)
|
|
680
|
+
WHERE id = p_shipment_id
|
|
681
|
+
RETURNING * INTO v_shipment;
|
|
682
|
+
|
|
683
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
684
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
685
|
+
VALUES (p_shipment_id, v_shipment.order_id, 'shipment_cancelled', p_idempotency_key,
|
|
686
|
+
jsonb_build_object('reason', p_reason), p_requested_at);
|
|
687
|
+
|
|
688
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'cancelled', false);
|
|
689
|
+
END;
|
|
690
|
+
$$;
|
|
691
|
+
|
|
692
|
+
-- Raise a shipment exception, and put the order on the hold the order rail authored.
|
|
693
|
+
--
|
|
694
|
+
-- The hold is created with a NULL creator on purpose: that is what marks it as raised by a machine,
|
|
695
|
+
-- it is the single field the one-way automatic release is allowed to key on, and it is the barrier
|
|
696
|
+
-- the emitter below reads. The order rail already encoded the release half of that asymmetry; this
|
|
697
|
+
-- function is the creating half, and it does not re-author any of it.
|
|
698
|
+
CREATE FUNCTION public.fulfillment_raise_shipment_exception(
|
|
699
|
+
p_idempotency_key text,
|
|
700
|
+
p_shipment_id uuid,
|
|
701
|
+
p_reason text,
|
|
702
|
+
p_metadata jsonb DEFAULT '{}'::jsonb,
|
|
703
|
+
p_requested_at timestamptz DEFAULT now()
|
|
704
|
+
)
|
|
705
|
+
RETURNS jsonb
|
|
706
|
+
LANGUAGE plpgsql
|
|
707
|
+
SET search_path = public, pg_catalog
|
|
708
|
+
AS $$
|
|
709
|
+
DECLARE
|
|
710
|
+
v_existing public.fulfillment_shipment_operations%ROWTYPE;
|
|
711
|
+
v_shipment public.fulfillment_shipments%ROWTYPE;
|
|
712
|
+
v_hold_id uuid;
|
|
713
|
+
BEGIN
|
|
714
|
+
IF p_idempotency_key IS NULL OR char_length(btrim(p_idempotency_key)) < 8
|
|
715
|
+
OR p_shipment_id IS NULL OR p_reason IS NULL OR btrim(p_reason) = ''
|
|
716
|
+
THEN
|
|
717
|
+
RAISE EXCEPTION 'fulfillment_raise_shipment_exception_invalid_input' USING ERRCODE = '22023';
|
|
718
|
+
END IF;
|
|
719
|
+
|
|
720
|
+
SELECT * INTO v_existing
|
|
721
|
+
FROM public.fulfillment_shipment_operations WHERE idempotency_key = p_idempotency_key;
|
|
722
|
+
IF FOUND THEN
|
|
723
|
+
IF v_existing.operation_type <> 'shipment_exception_raised'
|
|
724
|
+
OR v_existing.shipment_id IS DISTINCT FROM p_shipment_id
|
|
725
|
+
THEN
|
|
726
|
+
RAISE EXCEPTION 'fulfillment_raise_shipment_exception_idempotency_conflict'
|
|
727
|
+
USING ERRCODE = '23505';
|
|
728
|
+
END IF;
|
|
729
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id;
|
|
730
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'exception_raised', true);
|
|
731
|
+
END IF;
|
|
732
|
+
|
|
733
|
+
SELECT * INTO v_shipment FROM public.fulfillment_shipments WHERE id = p_shipment_id FOR UPDATE;
|
|
734
|
+
IF NOT FOUND THEN
|
|
735
|
+
RAISE EXCEPTION 'fulfillment_raise_shipment_exception_not_found' USING ERRCODE = '22023';
|
|
736
|
+
END IF;
|
|
737
|
+
IF v_shipment.status IN ('cancelled', 'delivered') THEN
|
|
738
|
+
RAISE EXCEPTION 'fulfillment_raise_shipment_exception_shipment_closed' USING ERRCODE = '22023';
|
|
739
|
+
END IF;
|
|
740
|
+
|
|
741
|
+
-- The hold goes in BEFORE the shipment moves to `exception`, and the order matters: the emitter
|
|
742
|
+
-- decides whether to say anything by reading where the parcel had got to, and a shipment already
|
|
743
|
+
-- rewritten to `exception` would have lost the answer to that question.
|
|
744
|
+
INSERT INTO public.commerce_order_holds
|
|
745
|
+
(order_id, reason, note, created_by, idempotency_key, metadata, created_at, updated_at)
|
|
746
|
+
VALUES (v_shipment.order_id, 'fulfillment_exception', p_reason, NULL,
|
|
747
|
+
p_idempotency_key || ':hold',
|
|
748
|
+
jsonb_build_object('shipmentId', v_shipment.id, 'reason', p_reason),
|
|
749
|
+
p_requested_at, p_requested_at)
|
|
750
|
+
ON CONFLICT (order_id, reason) WHERE status = 'active' DO NOTHING
|
|
751
|
+
RETURNING id INTO v_hold_id;
|
|
752
|
+
|
|
753
|
+
UPDATE public.fulfillment_shipments
|
|
754
|
+
SET status = 'exception',
|
|
755
|
+
updated_at = p_requested_at,
|
|
756
|
+
metadata = metadata || COALESCE(p_metadata, '{}'::jsonb)
|
|
757
|
+
|| jsonb_build_object('exceptionReason', p_reason)
|
|
758
|
+
WHERE id = p_shipment_id
|
|
759
|
+
RETURNING * INTO v_shipment;
|
|
760
|
+
|
|
761
|
+
INSERT INTO public.fulfillment_shipment_operations
|
|
762
|
+
(shipment_id, order_id, operation_type, idempotency_key, payload, occurred_at)
|
|
763
|
+
VALUES (p_shipment_id, v_shipment.order_id, 'shipment_exception_raised', p_idempotency_key,
|
|
764
|
+
jsonb_build_object('reason', p_reason, 'holdId', v_hold_id), p_requested_at);
|
|
765
|
+
|
|
766
|
+
RETURN public.fulfillment_shipment_response(v_shipment, 'exception_raised', false);
|
|
767
|
+
END;
|
|
768
|
+
$$;
|
|
769
|
+
|
|
770
|
+
-- The fourth emitter. A machine-raised fulfilment hold is a notification this capability owns.
|
|
771
|
+
--
|
|
772
|
+
-- The gate is the platform-observable fact and nothing else. Upstream the same emitter refuses
|
|
773
|
+
-- unless the hold's metadata carries one particular source string; that string names an integration
|
|
774
|
+
-- by brand, which this kernel may not write, and metadata is caller-supplied, which makes it the
|
|
775
|
+
-- wrong field to trust for a decision this consequential. The trigger's WHEN clause carries the
|
|
776
|
+
-- non-forgeable half -- a hold nobody claims to have created, raised for a fulfilment exception --
|
|
777
|
+
-- and the body carries the second gate the upstream emitter also has: say nothing once the parcel
|
|
778
|
+
-- is already out of our hands, because a customer whose parcel is in transit does not need to hear
|
|
779
|
+
-- that a system had a problem behind it.
|
|
780
|
+
CREATE FUNCTION public.fulfillment_emit_shipment_exception_notification()
|
|
781
|
+
RETURNS trigger
|
|
782
|
+
LANGUAGE plpgsql
|
|
783
|
+
SET search_path = public, pg_catalog
|
|
784
|
+
AS $$
|
|
785
|
+
BEGIN
|
|
786
|
+
IF EXISTS (
|
|
787
|
+
SELECT 1 FROM public.fulfillment_shipments
|
|
788
|
+
WHERE order_id = NEW.order_id
|
|
789
|
+
AND status IN ('handed_over', 'in_transit', 'delivered')
|
|
790
|
+
) THEN
|
|
791
|
+
RETURN NULL;
|
|
792
|
+
END IF;
|
|
793
|
+
|
|
794
|
+
INSERT INTO public.outbox_events
|
|
795
|
+
(aggregate_type, aggregate_id, event_type, idempotency_key, payload, metadata)
|
|
796
|
+
VALUES ('commerce_order', NEW.order_id, 'commerce.shipment.exception',
|
|
797
|
+
'shipment_exception:' || NEW.id::text,
|
|
798
|
+
jsonb_build_object('orderUuid', NEW.order_id, 'holdUuid', NEW.id,
|
|
799
|
+
'occurredAt', NEW.created_at),
|
|
800
|
+
jsonb_build_object('source', 'fulfillment_emit_shipment_exception_notification'))
|
|
801
|
+
ON CONFLICT (event_type, idempotency_key) DO NOTHING;
|
|
802
|
+
RETURN NULL;
|
|
803
|
+
END;
|
|
804
|
+
$$;
|
|
805
|
+
|
|
806
|
+
CREATE TRIGGER trg_fulfillment_emit_shipment_exception
|
|
807
|
+
AFTER INSERT ON public.commerce_order_holds
|
|
808
|
+
FOR EACH ROW
|
|
809
|
+
WHEN (NEW.reason = 'fulfillment_exception' AND NEW.created_by IS NULL AND NEW.status = 'active')
|
|
810
|
+
EXECUTE FUNCTION public.fulfillment_emit_shipment_exception_notification();
|